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 4d2d95d..5df174b 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 @@ -10,11 +10,14 @@ import android.widget.TextView; import java.lang.ref.WeakReference; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Collections; import java.util.IdentityHashMap; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.WeakHashMap; +import java.util.function.Function; import java.util.function.Predicate; import se.ajpanton.statusbartweak.runtime.features.RuntimeContext; @@ -22,7 +25,10 @@ import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport; import se.ajpanton.statusbartweak.runtime.hooks.XposedHookSupport; import se.ajpanton.statusbartweak.runtime.mode.SceneKey; import se.ajpanton.statusbartweak.runtime.mode.NotificationDisplayStyle; +import se.ajpanton.statusbartweak.runtime.mode.SystemNotificationDisplayStyleDetector; import se.ajpanton.statusbartweak.runtime.render.DebugBoundsOverlayController; +import se.ajpanton.statusbartweak.runtime.render.LockscreenCardsNotificationIconSourceStore; +import se.ajpanton.statusbartweak.runtime.render.LockscreenCardsNotificationStripRenderer; import se.ajpanton.statusbartweak.runtime.render.OwnedIconHostManager; import se.ajpanton.statusbartweak.runtime.render.UnlockedIconSnapshotRenderController; import se.ajpanton.statusbartweak.runtime.render.UnlockedIconSnapshotRenderController.RenderResult; @@ -37,6 +43,8 @@ final class StockLayoutCanvasController { private static final String CLASS_AOD_ICONS_CONTROLLER = "aod.T4"; private static final String CLASS_AOD_ICONS_ONLY_CONTAINER = "com.samsung.android.uniform.widget.notification.NotificationIconsOnlyContainer"; + private static final String CLASS_LEGACY_NOTIFICATION_ICON_AREA_CONTROLLER = + "com.android.systemui.statusbar.phone.LegacyNotificationIconAreaControllerImpl"; private static final Set TARGET_ID_NAMES = Set.of( "left_clock_container", @@ -60,6 +68,8 @@ final class StockLayoutCanvasController { private final Map hiddenViewStates = new WeakHashMap<>(); private final Map rootGeometries = new WeakHashMap<>(); private final Map debugOverlaySignatures = new WeakHashMap<>(); + private final Map settingsIdentityByRoot = new WeakHashMap<>(); + private final Map clockTimeBucketByRoot = new WeakHashMap<>(); private final Set confirmedAodPluginViews = Collections.newSetFromMap(new WeakHashMap<>()); private final Set layoutOffCleanedRoots = @@ -70,12 +80,18 @@ final class StockLayoutCanvasController { Collections.newSetFromMap(new WeakHashMap<>()); private final Set trackedLockedStatusBarIconSurfaces = Collections.newSetFromMap(new WeakHashMap<>()); + private final Set pendingUnlockedAppearanceRefreshRoots = + Collections.newSetFromMap(new WeakHashMap<>()); + private final Set dirtyUnlockedLayoutRoots = + Collections.newSetFromMap(new WeakHashMap<>()); private final Set hookedPluginClassLoaders = Collections.newSetFromMap(new IdentityHashMap<>()); private final Map unlockedHostsByRoot = new WeakHashMap<>(); private final OwnedIconHostManager ownedIconHostManager = new OwnedIconHostManager(); private final DebugBoundsOverlayController debugBoundsOverlayController = new DebugBoundsOverlayController(); + private final LockscreenCardsNotificationStripRenderer lockscreenCardsNotificationStripRenderer = + new LockscreenCardsNotificationStripRenderer(); 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 @@ -94,10 +110,212 @@ final class StockLayoutCanvasController { return; } installPluginManagerHooks(classLoader); + installCardsNotificationIconSourceHook(classLoader); + installUnlockedAppearanceInvalidationHooks(classLoader); installViewRootHook(classLoader); hooksInstalled = true; } + private void installUnlockedAppearanceInvalidationHooks(ClassLoader classLoader) { + Class darkIconDispatcherClass = ReflectionSupport.findClassIfExists( + "com.android.systemui.statusbar.phone.DarkIconDispatcherImpl", + classLoader); + if (darkIconDispatcherClass != null) { + XposedHookSupport.hookAllMethodsIfExists( + runtimeContext.getFramework(), + darkIconDispatcherClass, + "applyIconTint", + chain -> { + Object result = chain.proceed(); + scheduleUnlockedAppearanceRefreshForAllRoots(); + return result; + }); + } + + Class statusBarIconViewClass = ReflectionSupport.findClassIfExists( + "com.android.systemui.statusbar.StatusBarIconView", + classLoader); + if (statusBarIconViewClass != null) { + hookViewAppearanceInvalidation(statusBarIconViewClass, "onDarkChanged"); + hookViewAppearanceInvalidation(statusBarIconViewClass, "setStaticDrawableColor"); + hookViewAppearanceInvalidation(statusBarIconViewClass, "setDecorColor"); + hookViewAppearanceInvalidation(statusBarIconViewClass, "setIconColor"); + hookViewAppearanceInvalidation(statusBarIconViewClass, "updateIconColor"); + hookViewLayoutInvalidation(statusBarIconViewClass, "onLayout"); + hookViewLayoutInvalidation(statusBarIconViewClass, "setVisibleState"); + } + + Class notificationIconAreaClass = ReflectionSupport.findClassIfExists( + CLASS_LEGACY_NOTIFICATION_ICON_AREA_CONTROLLER, + classLoader); + if (notificationIconAreaClass != null) { + XposedHookSupport.hookAllMethodsIfExists( + runtimeContext.getFramework(), + notificationIconAreaClass, + "onDarkChanged", + chain -> { + Object result = chain.proceed(); + scheduleUnlockedAppearanceRefreshForAllRoots(); + return result; + }); + XposedHookSupport.hookAllMethodsIfExists( + runtimeContext.getFramework(), + notificationIconAreaClass, + "updateTintForIcon", + chain -> { + Object result = chain.proceed(); + Object icon = chain.getArgs().isEmpty() ? null : chain.getArg(0); + if (icon instanceof View view) { + scheduleUnlockedAppearanceRefresh(view); + } else { + scheduleUnlockedAppearanceRefreshForAllRoots(); + } + return result; + }); + } + + Class clockClass = ReflectionSupport.findClassIfExists( + "com.android.systemui.statusbar.policy.QSClockIndicatorView", + classLoader); + if (clockClass != null) { + hookViewAppearanceInvalidation(clockClass, "onDarkChanged"); + hookViewAppearanceInvalidation(clockClass, "setTextColor"); + hookClockTextInvalidation(clockClass, "notifyTimeChanged"); + hookClockTextInvalidation(clockClass, "setText"); + hookViewLayoutInvalidation(clockClass, "onLayout"); + } + + Class notificationContainerClass = ReflectionSupport.findClassIfExists( + "com.android.systemui.statusbar.phone.NotificationIconContainer", + classLoader); + if (notificationContainerClass != null) { + hookViewLayoutInvalidation(notificationContainerClass, "onLayout"); + hookViewLayoutInvalidation(notificationContainerClass, "calculateIconXTranslations"); + } + + Class statusIconContainerClass = ReflectionSupport.findClassIfExists( + "com.android.systemui.statusbar.phone.StatusIconContainer", + classLoader); + if (statusIconContainerClass != null) { + hookViewLayoutInvalidation(statusIconContainerClass, "onLayout"); + } + + Class batteryClass = ReflectionSupport.findClassIfExists( + "com.android.systemui.battery.BatteryMeterView", + classLoader); + if (batteryClass != null) { + hookViewLayoutInvalidation(batteryClass, "onLayout"); + } + } + + private void hookViewAppearanceInvalidation(Class type, String methodName) { + XposedHookSupport.hookAllMethodsIfExists( + runtimeContext.getFramework(), + type, + methodName, + chain -> { + Object result = chain.proceed(); + if (chain.getThisObject() instanceof View view) { + scheduleUnlockedAppearanceRefresh(view); + } else { + scheduleUnlockedAppearanceRefreshForAllRoots(); + } + return result; + }); + } + + private void hookViewLayoutInvalidation(Class type, String methodName) { + XposedHookSupport.hookAllMethodsIfExists( + runtimeContext.getFramework(), + type, + methodName, + chain -> { + Object result = chain.proceed(); + if (chain.getThisObject() instanceof View view) { + markUnlockedLayoutDirty(view); + } + return result; + }); + } + + private void hookClockTextInvalidation(Class type, String methodName) { + XposedHookSupport.hookAllMethodsIfExists( + runtimeContext.getFramework(), + type, + methodName, + chain -> { + Object result = chain.proceed(); + if (chain.getThisObject() instanceof View view) { + scheduleUnlockedClockTextRefresh(view); + } + return result; + }); + } + + private void installCardsNotificationIconSourceHook(ClassLoader classLoader) { + Class controllerClass = ReflectionSupport.findClassIfExists( + CLASS_LEGACY_NOTIFICATION_ICON_AREA_CONTROLLER, + classLoader); + if (controllerClass == null) { + return; + } + XposedHookSupport.hookAllMethodsIfExists( + runtimeContext.getFramework(), + controllerClass, + "updateIconsForLayout", + chain -> { + List args = chain.getArgs(); + int functionIndex = -1; + int containerIndex = -1; + for (int i = 0; i < args.size(); i++) { + Object arg = args.get(i); + if (functionIndex < 0 && arg instanceof Function) { + functionIndex = i; + } + if (containerIndex < 0 + && arg instanceof View view + && view.getClass().getName().contains("NotificationIconContainer")) { + containerIndex = i; + } + } + if (functionIndex < 0 || containerIndex < 0) { + return chain.proceed(); + } + Object containerObj = args.get(containerIndex); + if (!(containerObj instanceof ViewGroup container)) { + return chain.proceed(); + } + if (!isCardsNotificationIconSourceContainer(container)) { + Object result = chain.proceed(); + markUnlockedLayoutDirty(container); + return result; + } + @SuppressWarnings("unchecked") + Function original = (Function) args.get(functionIndex); + ArrayList captured = + new ArrayList<>(); + Function wrapped = entryObj -> { + String key = LockscreenCardsNotificationIconSourceStore.keyForEntry(entryObj); + Object result = original.apply(entryObj); + if (result == null) { + result = fallbackStatusBarIcon(entryObj); + } + if (result instanceof View view && !isStockOverflowDotView(view)) { + if (key == null) { + key = LockscreenCardsNotificationIconSourceStore.keyForIconView(view); + } + captured.add(LockscreenCardsNotificationIconSourceStore.source(view, key)); + } + return result; + }; + Object[] newArgs = args.toArray(new Object[0]); + newArgs[functionIndex] = wrapped; + Object result = chain.proceed(newArgs); + LockscreenCardsNotificationIconSourceStore.put(container, captured); + return result; + }); + } + private void installPluginManagerHooks(ClassLoader classLoader) { Class managerClass = ReflectionSupport.findClassIfExists(CLASS_PLUGIN_AOD_MANAGER, classLoader); if (managerClass == null) { @@ -176,6 +394,10 @@ final class StockLayoutCanvasController { } private void applyBeforeDraw(View root) { + applyBeforeDrawInternal(root); + } + + private void applyBeforeDrawInternal(View root) { if (root == null) { return; } @@ -190,31 +412,182 @@ final class StockLayoutCanvasController { if (activeScene != null && !activeScene.isUnlocked()) { hideTrackedLockedStatusBarIconSurfaces(null); } + if (activeScene != null + && activeScene.isLockscreen() + && activeScene.notificationDisplayStyle() == NotificationDisplayStyle.CARDS + && canContainLockedStatusBarIconSurfaces(root)) { + renderLockscreenCardsNotificationStripIfNeeded(root, settings, activeScene); + } if (!isUnlockedStatusBarRoot(root)) { return; } - if (activeScene != null && (activeScene.isUnlocked() || activeScene.isLockscreen())) { + if (activeScene != null && activeScene.isUnlocked()) { if (isKnownRootGeometryChanged(root)) { ownedIconHostManager.setUnlockedHostsVisible(root, false); removeDebugOverlay(root); } - hideTrackedUnlockedStockSurfaces(root); - UnlockedHosts unlockedHosts = unlockedHostsByRoot.get(root); - if (unlockedHosts != null) { - unlockedIconRenderController.refreshUnlockedAppearance( - root, - unlockedHosts.clockHost, - unlockedHosts.carrierHost, - unlockedHosts.chipHost, - unlockedHosts.statusHost, - unlockedHosts.notificationHost, - activeScene); - ownedIconHostManager.bringUnlockedHostsToFront(root); - } } } + private void scheduleUnlockedAppearanceRefresh(View sourceView) { + View root = unlockedStatusBarRootFor(sourceView); + if (root == null) { + return; + } + scheduleUnlockedAppearanceRefreshForRoot(root); + } + + private void scheduleUnlockedClockTextRefresh(View sourceView) { + View root = unlockedStatusBarRootFor(sourceView); + if (root == null) { + return; + } + scheduleUnlockedClockTextRefreshForRoot(root); + } + + private void markUnlockedLayoutDirty(View sourceView) { + View root = unlockedStatusBarRootFor(sourceView); + if (root != null) { + dirtyUnlockedLayoutRoots.add(root); + } + } + + private void markDirtyIfSettingsChanged(View root, SbtSettings settings, boolean statusBarRoot) { + if (!statusBarRoot || root == null || settings == null) { + return; + } + int identity = System.identityHashCode(settings); + Integer previous = settingsIdentityByRoot.put(root, identity); + if (previous != null && previous != identity) { + dirtyUnlockedLayoutRoots.add(root); + } + } + + private void markDirtyIfClockTimeChanged(View root, SbtSettings settings, boolean statusBarRoot) { + if (!statusBarRoot || root == null || settings == null || !settings.clockEnabledUnlocked) { + return; + } + long bucket = clockTimeBucket(settings); + Long previous = clockTimeBucketByRoot.put(root, bucket); + if (previous != null && previous != bucket) { + scheduleUnlockedClockTextRefreshForRoot(root); + } + } + + private long clockTimeBucket(SbtSettings settings) { + if (settings == null + || (!settings.clockShowSeconds + && !settings.clockShowDatePrefix + && !settings.clockCustomFormatEnabled)) { + return 0L; + } + String pattern = settings.clockCustomFormat != null ? settings.clockCustomFormat : ""; + boolean seconds = settings.clockShowSeconds + || (settings.clockCustomFormatEnabled && pattern.contains("s")); + long divisor = seconds ? 1_000L : 60_000L; + return System.currentTimeMillis() / divisor; + } + + private void scheduleUnlockedAppearanceRefreshForAllRoots() { + for (View root : new ArrayList<>(unlockedHostsByRoot.keySet())) { + scheduleUnlockedAppearanceRefreshForRoot(root); + } + } + + private void scheduleUnlockedAppearanceRefreshForRoot(View root) { + if (root == null + || !root.isAttachedToWindow() + || !isUnlockedStatusBarRoot(root) + || !pendingUnlockedAppearanceRefreshRoots.add(root)) { + return; + } + root.post(() -> { + pendingUnlockedAppearanceRefreshRoots.remove(root); + refreshUnlockedAppearanceNow(root); + }); + } + + private void scheduleUnlockedClockTextRefreshForRoot(View root) { + if (root == null + || !root.isAttachedToWindow() + || !isUnlockedStatusBarRoot(root) + || !pendingUnlockedAppearanceRefreshRoots.add(root)) { + return; + } + root.post(() -> { + pendingUnlockedAppearanceRefreshRoots.remove(root); + refreshUnlockedClockTextNow(root); + }); + } + + private void refreshUnlockedAppearanceNow(View root) { + if (root == null || !root.isAttachedToWindow()) { + return; + } + SbtSettings settings = RuntimeSettingsCache.get(root.getContext()); + if (!settings.clockEnabled) { + return; + } + SceneKey activeScene = runtimeContext.getModeStateRepository().getActiveScene(); + if (activeScene == null || !activeScene.isUnlocked()) { + return; + } + UnlockedHosts unlockedHosts = unlockedHostsByRoot.get(root); + if (unlockedHosts == null || !areUnlockedHostsAttached(root, unlockedHosts)) { + return; + } + unlockedIconRenderController.refreshUnlockedAppearance( + root, + unlockedHosts.clockHost, + unlockedHosts.carrierHost, + unlockedHosts.chipHost, + unlockedHosts.statusHost, + unlockedHosts.notificationHost, + activeScene); + ownedIconHostManager.bringUnlockedHostsToFront(root); + } + + private View unlockedStatusBarRootFor(View view) { + View current = view; + while (current != null) { + if (isUnlockedStatusBarRoot(current) && unlockedHostsByRoot.containsKey(current)) { + return current; + } + Object parent = current.getParent(); + current = parent instanceof View ? (View) parent : null; + } + return null; + } + private void applyToRoot(View root) { + applyToRootInternal(root); + } + + private void refreshUnlockedClockTextNow(View root) { + if (root == null || !root.isAttachedToWindow()) { + return; + } + SbtSettings settings = RuntimeSettingsCache.get(root.getContext()); + if (!settings.clockEnabled || !settings.clockEnabledUnlocked) { + return; + } + SceneKey activeScene = runtimeContext.getModeStateRepository().getActiveScene(); + if (activeScene == null || !activeScene.isUnlocked()) { + return; + } + UnlockedHosts unlockedHosts = unlockedHostsByRoot.get(root); + if (unlockedHosts == null || !areUnlockedHostsAttached(root, unlockedHosts)) { + return; + } + if (unlockedIconRenderController.refreshUnlockedClockText( + root, + unlockedHosts.clockHost, + activeScene)) { + ownedIconHostManager.bringUnlockedHostsToFront(root); + } + } + + private void applyToRootInternal(View root) { if (root == null) { return; } @@ -222,14 +595,12 @@ final class StockLayoutCanvasController { SbtSettings settings = RuntimeSettingsCache.get(context); boolean layoutEnabled = settings.clockEnabled; boolean statusBarRoot = isUnlockedStatusBarRoot(root); + markDirtyIfSettingsChanged(root, settings, statusBarRoot); + markDirtyIfClockTimeChanged(root, settings, statusBarRoot); runtimeContext.getModeStateRepository().setLayoutEnabled(layoutEnabled); if (!layoutEnabled) { - if (statusBarRoot) { - applyDebugOverlayIfNeeded(root, false, settings, false); - } else { - removeDebugOverlay(root); - } - cleanupLayoutOffRoot(root); + updateActiveScene(root, context); + applyLayoutOffRoot(root, settings, statusBarRoot, runtimeContext.getModeStateRepository().getActiveScene()); return; } layoutOffCleanedRoots.remove(root); @@ -238,7 +609,10 @@ final class StockLayoutCanvasController { boolean unlockedScene = activeScene != null && activeScene.isUnlocked(); if (!statusBarRoot) { if (!unlockedScene && canContainLockedStatusBarIconSurfaces(root)) { + renderLockscreenCardsNotificationStripIfNeeded(root, settings, activeScene); hideOrDiscoverLockedStatusBarIconSurfaces(root); + } else { + removeLockscreenCardsNotificationHost(root); } removeDebugOverlay(root); return; @@ -254,20 +628,25 @@ final class StockLayoutCanvasController { if (!unlockedScene) { restoreTrackedLockedStatusBarIconSurfaces(root); } - markRootGeometryBeforeDraw(root); - renderResult = unlockedIconRenderController.renderUnlocked( - root, - unlockedHosts.clockHost, - unlockedHosts.carrierHost, - unlockedHosts.chipHost, - unlockedHosts.statusHost, - unlockedHosts.notificationHost, - activeScene, - lockedCarrierTextSource()); - boolean shouldShowUnlockedHosts = unlockedScene || renderResult.shouldOwnLockscreen(); - ownedIconHostManager.setUnlockedHostsVisible(root, shouldShowUnlockedHosts); - if (shouldShowUnlockedHosts) { - ownedIconHostManager.bringUnlockedHostsToFront(root); + boolean shouldRender = !unlockedIconRenderController.hasLayoutPlan(root) + || dirtyUnlockedLayoutRoots.remove(root) + || !unlockedScene; + if (shouldRender) { + markRootGeometryBeforeDraw(root); + renderResult = unlockedIconRenderController.renderUnlocked( + root, + unlockedHosts.clockHost, + unlockedHosts.carrierHost, + unlockedHosts.chipHost, + unlockedHosts.statusHost, + unlockedHosts.notificationHost, + activeScene, + lockedCarrierTextSource()); + boolean shouldShowUnlockedHosts = unlockedScene || renderResult.shouldOwnLockscreen(); + ownedIconHostManager.setUnlockedHostsVisible(root, shouldShowUnlockedHosts); + if (shouldShowUnlockedHosts) { + ownedIconHostManager.bringUnlockedHostsToFront(root); + } } } if (!unlockedScene) { @@ -306,6 +685,40 @@ final class StockLayoutCanvasController { }); } + private void applyLayoutOffRoot( + View root, + SbtSettings settings, + boolean statusBarRoot, + SceneKey activeScene + ) { + if (statusBarRoot) { + applyDebugOverlayIfNeeded(root, false, settings, false); + } else { + removeDebugOverlay(root); + } + cleanupLayoutOffRoot(root, activeScene); + suppressLayoutOffShadeSurfaces(root, statusBarRoot, activeScene); + } + + private void suppressLayoutOffShadeSurfaces( + View root, + boolean statusBarRoot, + SceneKey activeScene + ) { + if (statusBarRoot + || root == null + || activeScene == null + || !canContainLockedStatusBarIconSurfaces(root)) { + return; + } + boolean shadeSurfaceShouldStayHidden = activeScene.isUnlocked() + || activeScene.isLockscreen() + && runtimeContext.getModeStateRepository().isSystemUiShadeLocked(); + if (shadeSurfaceShouldStayHidden) { + hideOrDiscoverLockedStatusBarIconSurfaces(root); + } + } + private void applyDebugOverlay( View root, boolean layoutEnabled, @@ -442,7 +855,7 @@ final class StockLayoutCanvasController { if (isLockedStatusBarIconSurface(view)) { rememberLockedCarrierTextSource(view); trackedLockedStatusBarIconSurfaces.add(view); - hideView(view, isCarrierView(view)); + hideView(view, keepLockedSurfaceLaidOut(view)); } }); } @@ -467,7 +880,7 @@ final class StockLayoutCanvasController { } if (root == null || isDescendantOf(view, root)) { foundInRoot = true; - hideView(view, isCarrierView(view)); + hideView(view, keepLockedSurfaceLaidOut(view)); } } while (!staleViews.isEmpty()) { @@ -531,7 +944,7 @@ final class StockLayoutCanvasController { String viewClassName = view.getClass().getName(); boolean carrierSurface = idName.toLowerCase(java.util.Locale.ROOT).contains("carrier") || viewClassName.toLowerCase(java.util.Locale.ROOT).contains("carrier"); - boolean shelfSurface = viewClassName.contains("NotificationShelf"); + boolean shelfSurface = isCardsNotificationIconSurface(view); boolean shadeIconOnlySurface = "notification_icononly_container".equals(idName) || "notification_icononly_area".equals(idName) || "keyguard_icononly_container_view".equals(idName) @@ -573,6 +986,54 @@ final class StockLayoutCanvasController { return false; } + private boolean keepLockedSurfaceLaidOut(View view) { + return isCarrierView(view) || isCardsNotificationIconSurface(view); + } + + private boolean isCardsNotificationIconSurface(View view) { + if (view == null) { + return false; + } + String idName = resolveIdName(view); + String className = view.getClass().getName(); + return className.contains("SecShelfNotificationIconContainer") + || className.contains("NotificationShelfBackground") + || "notification_icononly_container".equals(idName) + || "notification_icononly_area".equals(idName) + || "keyguard_icononly_container_view".equals(idName) + || "common_notification_widget".equals(idName); + } + + private boolean isCardsNotificationShelfSurface(View view) { + if (view == null) { + return false; + } + String className = view.getClass().getName(); + return className.contains("SecShelfNotificationIconContainer") + || className.contains("NotificationShelfBackground"); + } + + private boolean isCardsNotificationIconSourceContainer(View view) { + return view != null && view.getClass().getName().contains("SecShelfNotificationIconContainer"); + } + + private Object fallbackStatusBarIcon(Object entryObj) { + Object icons = ReflectionSupport.getFieldValue(entryObj, "mIcons"); + return icons != null ? ReflectionSupport.getFieldValue(icons, "mStatusBarIcon") : null; + } + + private boolean isStockOverflowDotView(View view) { + if (view == null) { + return false; + } + String idName = resolveIdName(view).toLowerCase(java.util.Locale.ROOT); + String className = view.getClass().getName().toLowerCase(java.util.Locale.ROOT); + return idName.contains("overflow") + || idName.contains("dot") + || className.contains("overflow") + || className.contains("dot"); + } + private boolean isCarrierView(View view) { if (view == null) { return false; @@ -703,6 +1164,10 @@ final class StockLayoutCanvasController { unlockedIconRenderController.clearAll(); ownedIconHostManager.removeUnlockedHosts(root); unlockedHostsByRoot.remove(root); + pendingUnlockedAppearanceRefreshRoots.remove(root); + dirtyUnlockedLayoutRoots.remove(root); + settingsIdentityByRoot.remove(root); + clockTimeBucketByRoot.remove(root); removeDebugOverlay(root); root.requestLayout(); root.invalidate(); @@ -807,28 +1272,74 @@ final class StockLayoutCanvasController { return true; } - private void releaseHiddenViews() { + private boolean releaseHiddenViews(SceneKey activeScene) { + boolean unlockedScene = activeScene != null && activeScene.isUnlocked(); + boolean steadyLockscreenScene = activeScene != null + && activeScene.isLockscreen() + && !runtimeContext.getModeStateRepository().isSystemUiShadeLocked(); + boolean lockscreenShadeScene = activeScene != null + && activeScene.isLockscreen() + && runtimeContext.getModeStateRepository().isSystemUiShadeLocked(); + boolean deferredLockedShadeSurfaces = false; ArrayDeque views = new ArrayDeque<>(hiddenViewStates.keySet()); while (!views.isEmpty()) { View view = views.removeFirst(); - hiddenViewStates.remove(view); - view.setAlpha(1f); - view.setVisibility(View.VISIBLE); + boolean wasUnlockedStockSurface = trackedUnlockedStockSurfaces.contains(view); + boolean wasLockedShadeSurface = trackedLockedStatusBarIconSurfaces.contains(view) + && isDescendantOfNotificationShadeWindow(view); + if (unlockedScene && wasLockedShadeSurface) { + deferredLockedShadeSurfaces = true; + view.setAlpha(0f); + view.setVisibility(View.GONE); + requestViewAndParentLayout(view); + continue; + } + if (lockscreenShadeScene && wasLockedShadeSurface) { + deferredLockedShadeSurfaces = true; + view.setAlpha(0f); + view.setVisibility(View.GONE); + requestViewAndParentLayout(view); + continue; + } + if (steadyLockscreenScene + && wasLockedShadeSurface + && !isCardsNotificationShelfSurface(view)) { + deferredLockedShadeSurfaces = true; + view.setAlpha(0f); + view.setVisibility(View.GONE); + requestViewAndParentLayout(view); + continue; + } + restoreView(view); + if ((unlockedScene && wasUnlockedStockSurface) + || (steadyLockscreenScene && isCardsNotificationShelfSurface(view))) { + forceViewVisible(view); + } } - hiddenViewStates.clear(); trackedStatusChipSources.clear(); - trackedLockedStatusBarIconSurfaces.clear(); + if (!deferredLockedShadeSurfaces) { + trackedLockedStatusBarIconSurfaces.clear(); + } trackedUnlockedStockSurfaces.clear(); + return deferredLockedShadeSurfaces; } - private void cleanupLayoutOffRoot(View root) { + private void cleanupLayoutOffRoot(View root, SceneKey activeScene) { + boolean deferredLockedShadeSurfaces = false; if (!hiddenViewStates.isEmpty()) { - releaseHiddenViews(); + deferredLockedShadeSurfaces = releaseHiddenViews(activeScene); } trackedStatusChipSources.clear(); - trackedLockedStatusBarIconSurfaces.clear(); + if (!deferredLockedShadeSurfaces) { + trackedLockedStatusBarIconSurfaces.clear(); + } trackedUnlockedStockSurfaces.clear(); + pendingUnlockedAppearanceRefreshRoots.remove(root); + dirtyUnlockedLayoutRoots.remove(root); + settingsIdentityByRoot.remove(root); + clockTimeBucketByRoot.remove(root); if (!isUnlockedStatusBarRoot(root) || !layoutOffCleanedRoots.add(root)) { + removeLockscreenCardsNotificationHost(root); return; } rootGeometries.remove(root); @@ -836,6 +1347,40 @@ final class StockLayoutCanvasController { ownedIconHostManager.removeUnlockedHosts(root); } + private void forceViewVisible(View view) { + if (view == null) { + return; + } + view.setAlpha(1f); + view.setVisibility(View.VISIBLE); + requestViewAndParentLayout(view); + } + + private void requestViewAndParentLayout(View view) { + if (view == null) { + return; + } + view.requestLayout(); + view.invalidate(); + Object parent = view.getParent(); + if (parent instanceof View parentView) { + parentView.requestLayout(); + parentView.invalidate(); + } + } + + private boolean isDescendantOfNotificationShadeWindow(View view) { + View current = view; + while (current != null) { + if (current.getClass().getName().contains("NotificationShadeWindowView")) { + return true; + } + Object parent = current.getParent(); + current = parent instanceof View ? (View) parent : null; + } + return false; + } + private boolean isDescendantOf(View child, View ancestor) { if (child == null || ancestor == null) { return false; @@ -871,6 +1416,97 @@ final class StockLayoutCanvasController { } } + private void renderLockscreenCardsNotificationStripIfNeeded( + View root, + SbtSettings settings, + SceneKey activeScene + ) { + View visibleBouncerSurface = findVisibleBouncerSurface(root); + boolean suppressForTransition = runtimeContext.getModeStateRepository().isSystemUiShadeLocked() + || visibleBouncerSurface != null; + if (root == null + || settings == null + || activeScene == null + || !activeScene.isLockscreen() + || activeScene.notificationDisplayStyle() != NotificationDisplayStyle.CARDS + || suppressForTransition) { + removeLockscreenCardsNotificationHost(root); + return; + } + View host = ownedIconHostManager.ensureLockscreenCardsNotificationHost(root); + if (host instanceof ViewGroup hostGroup) { + lockscreenCardsNotificationStripRenderer.render(root, hostGroup, settings, activeScene); + host.bringToFront(); + } + } + + private View findVisibleBouncerSurface(View root) { + if (root == null) { + return null; + } + ArrayDeque queue = new ArrayDeque<>(); + queue.add(root); + while (!queue.isEmpty()) { + View view = queue.removeFirst(); + if (view != root && isVisibleBouncerSurface(view)) { + return view; + } + if (view instanceof ViewGroup group) { + for (int i = 0; i < group.getChildCount(); i++) { + View child = group.getChildAt(i); + if (child != null) { + queue.addLast(child); + } + } + } + } + return null; + } + + private boolean isVisibleBouncerSurface(View view) { + if (view == null || view.getVisibility() != View.VISIBLE || view.getAlpha() <= 0f) { + return false; + } + if (view.getWidth() <= 0 || view.getHeight() <= 0) { + return false; + } + String idName = resolveIdName(view).toLowerCase(java.util.Locale.ROOT); + String className = view.getClass().getName().toLowerCase(java.util.Locale.ROOT); + return idName.contains("bouncer") + || idName.contains("keyguard_security") + || idName.contains("keyguard_pin") + || idName.contains("keyguard_password") + || idName.contains("keyguard_pattern") + || className.contains("bouncer") + || className.contains("keyguardsecurity") + || className.contains("keyguardpin") + || className.contains("keyguardpassword") + || className.contains("keyguardpattern"); + } + + private void removeLockscreenCardsNotificationHost(View root) { + View host = root instanceof ViewGroup group + ? findDirectOwnedHost(group, OwnedIconHostManager.TAG_LOCKSCREEN_CARDS_NOTIFICATION_HOST) + : null; + if (host instanceof ViewGroup hostGroup) { + lockscreenCardsNotificationStripRenderer.clear(hostGroup); + } + ownedIconHostManager.removeHost(root, OwnedIconHostManager.TAG_LOCKSCREEN_CARDS_NOTIFICATION_HOST); + } + + private View findDirectOwnedHost(ViewGroup root, String tag) { + if (root == null || tag == null) { + return null; + } + for (int i = 0; i < root.getChildCount(); i++) { + View child = root.getChildAt(i); + if (tag.equals(child.getTag())) { + return child; + } + } + return null; + } + private UnlockedHosts updateUnlockedOwnedHosts( View root, SceneKey activeScene @@ -903,6 +1539,10 @@ final class StockLayoutCanvasController { unlockedIconRenderController.clearAll(); ownedIconHostManager.removeUnlockedHosts(root); unlockedHostsByRoot.remove(root); + pendingUnlockedAppearanceRefreshRoots.remove(root); + dirtyUnlockedLayoutRoots.remove(root); + settingsIdentityByRoot.remove(root); + clockTimeBucketByRoot.remove(root); return null; } @@ -932,12 +1572,14 @@ final class StockLayoutCanvasController { if (runtimeContext.getModeStateRepository().hasSystemUiStatusBarState()) { return; } + NotificationDisplayStyle notificationStyle = + SystemNotificationDisplayStyleDetector.read(context); if (isAodRoot(root) || (isKeyguardLocked(context) && !isInteractive(context))) { - runtimeContext.getModeStateRepository().setAod(NotificationDisplayStyle.UNKNOWN); + runtimeContext.getModeStateRepository().setAod(notificationStyle); return; } if (isKeyguardLocked(context)) { - runtimeContext.getModeStateRepository().setLockscreen(NotificationDisplayStyle.UNKNOWN); + runtimeContext.getModeStateRepository().setLockscreen(notificationStyle); return; } runtimeContext.getModeStateRepository().setUnlocked(); diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/mode/ModeStateRepository.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/mode/ModeStateRepository.java index 9c891b1..da30646 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/runtime/mode/ModeStateRepository.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/mode/ModeStateRepository.java @@ -16,6 +16,7 @@ public final class ModeStateRepository { private SceneKey activeScene = SceneKey.unlocked(); private boolean systemUiStatusBarStateKnown; + private int systemUiStatusBarState; private boolean layoutEnabled; private final List layoutEnabledListeners = new ArrayList<>(); @@ -27,6 +28,10 @@ public final class ModeStateRepository { return systemUiStatusBarStateKnown; } + public synchronized boolean isSystemUiShadeLocked() { + return systemUiStatusBarStateKnown && systemUiStatusBarState == 2; + } + public synchronized boolean isLayoutEnabled() { return layoutEnabled; } @@ -67,14 +72,23 @@ public final class ModeStateRepository { activeScene = Objects.requireNonNull(sceneKey, "sceneKey"); } - public synchronized void setSystemUiStatusBarState(int state, boolean dozing) { + public synchronized void setSystemUiStatusBarState( + int state, + boolean dozing, + NotificationDisplayStyle notificationDisplayStyle + ) { systemUiStatusBarStateKnown = true; + systemUiStatusBarState = state; + NotificationDisplayStyle style = notificationDisplayStyle; + if (style == null || style == NotificationDisplayStyle.NONE) { + style = NotificationDisplayStyle.UNKNOWN; + } if (dozing) { - activeScene = SceneKey.aod(NotificationDisplayStyle.UNKNOWN); + activeScene = SceneKey.aod(style); } else if (state == 0) { activeScene = SceneKey.unlocked(); } else { - activeScene = SceneKey.lockscreen(NotificationDisplayStyle.UNKNOWN); + activeScene = SceneKey.lockscreen(style); } } diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/mode/SystemNotificationDisplayStyleDetector.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/mode/SystemNotificationDisplayStyleDetector.java new file mode 100644 index 0000000..29a2ced --- /dev/null +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/mode/SystemNotificationDisplayStyleDetector.java @@ -0,0 +1,39 @@ +package se.ajpanton.statusbartweak.runtime.mode; + +import android.content.ContentResolver; +import android.content.Context; +import android.provider.Settings; + +/** + * Reads Samsung's lockscreen/AOD notification display style from the system + * setting SystemUI itself uses for Cards / Icons / Dot. + */ +public final class SystemNotificationDisplayStyleDetector { + private static final String KEY_LOCKSCREEN_MINIMIZING_NOTIFICATION = + "lockscreen_minimizing_notification"; + + private SystemNotificationDisplayStyleDetector() { + } + + public static NotificationDisplayStyle read(Context context) { + ContentResolver resolver = context != null ? context.getContentResolver() : null; + if (resolver == null) { + return NotificationDisplayStyle.UNKNOWN; + } + try { + return styleForMinimizingValue( + Settings.System.getInt(resolver, KEY_LOCKSCREEN_MINIMIZING_NOTIFICATION)); + } catch (Throwable ignored) { + return NotificationDisplayStyle.UNKNOWN; + } + } + + static NotificationDisplayStyle styleForMinimizingValue(int value) { + return switch (value) { + case 0 -> NotificationDisplayStyle.CARDS; + case 1 -> NotificationDisplayStyle.ICONS; + case 2 -> NotificationDisplayStyle.DOT; + default -> NotificationDisplayStyle.UNKNOWN; + }; + } +} diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/mode/SystemUiStatusBarStateFeature.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/mode/SystemUiStatusBarStateFeature.java index 3bc554b..ee0e8b7 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/runtime/mode/SystemUiStatusBarStateFeature.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/mode/SystemUiStatusBarStateFeature.java @@ -1,5 +1,7 @@ package se.ajpanton.statusbartweak.runtime.mode; +import android.content.Context; + import io.github.libxposed.api.XposedModuleInterface; import se.ajpanton.statusbartweak.runtime.features.RuntimeContext; import se.ajpanton.statusbartweak.runtime.features.RuntimeFeature; @@ -65,6 +67,26 @@ public final class SystemUiStatusBarStateFeature implements RuntimeFeature { Object rawState = ReflectionSupport.getFieldValue(controller, "mState"); int state = rawState instanceof Integer integer ? integer : 0; boolean dozing = ReflectionSupport.getBooleanField(controller, "mIsDozing", false); - context.getModeStateRepository().setSystemUiStatusBarState(state, dozing); + context.getModeStateRepository().setSystemUiStatusBarState( + state, + dozing, + SystemNotificationDisplayStyleDetector.read(systemContext(controller))); + } + + private Context systemContext(Object controller) { + Object context = ReflectionSupport.getFieldValue(controller, "mContext"); + if (context instanceof Context androidContext) { + return androidContext; + } + try { + Class activityThreadClass = Class.forName("android.app.ActivityThread"); + Object app = ReflectionSupport.invokeStaticMethod( + activityThreadClass, + "currentApplication", + new Class[0]); + return app instanceof Context androidContext ? androidContext : null; + } catch (Throwable ignored) { + return null; + } } } 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 new file mode 100644 index 0000000..e1588c4 --- /dev/null +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/LockscreenCardsNotificationIconSourceStore.java @@ -0,0 +1,287 @@ +package se.ajpanton.statusbartweak.runtime.render; + +import android.view.View; +import android.view.ViewGroup; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; +import java.util.WeakHashMap; + +import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport; + +public final class LockscreenCardsNotificationIconSourceStore { + private static final WeakHashMap> SOURCES_BY_CONTAINER = + new WeakHashMap<>(); + + private LockscreenCardsNotificationIconSourceStore() { + } + + public static void put(ViewGroup container, ArrayList sources) { + if (container == null || sources == null || sources.isEmpty()) { + if (container != null) { + SOURCES_BY_CONTAINER.remove(container); + } + return; + } + ArrayList attached = new ArrayList<>(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (SourceEntry source : sources) { + if (source == null || source.view == null || !seen.add(source.view)) { + continue; + } + attached.add(source); + } + if (attached.isEmpty()) { + SOURCES_BY_CONTAINER.remove(container); + } else { + SOURCES_BY_CONTAINER.put(container, attached); + } + } + + static ArrayList snapshotSources(ViewGroup container, View root) { + ArrayList views = SOURCES_BY_CONTAINER.get(container); + ArrayList result = new ArrayList<>(); + if (views == null || views.isEmpty()) { + return result; + } + int skipCards = visibleShelfStartIndex(container, views); + for (int i = 0; i < views.size(); i++) { + if (i < skipCards) { + continue; + } + SourceEntry entry = views.get(i); + View view = entry.view; + if (view == null || !view.isAttachedToWindow()) { + continue; + } + int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth(); + int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight(); + if (width <= 0 || height <= 0) { + continue; + } + result.add(new SnapshotSource(view, SnapshotMode.NOTIFICATION_DRAWABLE)); + } + return result; + } + + private static int visibleShelfStartIndex(ViewGroup container, ArrayList entries) { + if (container == null || entries == null || entries.isEmpty()) { + return 0; + } + ArrayList visibleShelfIcons = visibleShelfIcons(container); + for (View icon : visibleShelfIcons) { + String key = keyForIconView(icon); + for (int i = 0; i < entries.size(); i++) { + SourceEntry entry = entries.get(i); + if (entry == null) { + continue; + } + if (entry.view == icon) { + return i; + } + if (key != null && entry.key != null && key.equals(entry.key)) { + return i; + } + } + } + return 0; + } + + private static ArrayList visibleShelfIcons(ViewGroup container) { + ArrayList result = new ArrayList<>(); + ArrayList stack = new ArrayList<>(); + stack.add(container); + for (int i = 0; i < stack.size(); i++) { + View view = stack.get(i); + if (view != container && isVisibleShelfIcon(view)) { + result.add(view); + continue; + } + if (view instanceof ViewGroup group) { + for (int child = 0; child < group.getChildCount(); child++) { + View childView = group.getChildAt(child); + if (childView != null) { + stack.add(childView); + } + } + } + } + return result; + } + + private static boolean isVisibleShelfIcon(View view) { + if (view == null || view.getVisibility() != View.VISIBLE) { + return false; + } + if (isOverflowDotCandidate(view)) { + return false; + } + int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth(); + int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight(); + if (width <= 0 || height <= 0) { + return false; + } + String className = view.getClass().getName(); + return className.contains("StatusBarIconView") + || className.contains("IconView") + || view instanceof android.widget.ImageView; + } + + private static boolean isOverflowDotCandidate(View view) { + String idName = resolveIdName(view).toLowerCase(java.util.Locale.ROOT); + String className = view.getClass().getName().toLowerCase(java.util.Locale.ROOT); + return idName.contains("overflow") + || idName.contains("dot") + || className.contains("overflow") + || className.contains("dot"); + } + + public static SourceEntry source(View view, String key) { + return new SourceEntry(view, key); + } + + public static String keyForEntry(Object entry) { + if (entry == null) { + return null; + } + Object methodValue = ReflectionSupport.invokeMethod(entry, "getKey", new Class[0]); + if (methodValue instanceof String string && !string.isEmpty()) { + return string; + } + for (String fieldName : new String[]{"mKey", "key", "notificationKey", "mNotificationKey"}) { + Object value = ReflectionSupport.getFieldValue(entry, fieldName); + if (value instanceof String string && !string.isEmpty()) { + return string; + } + } + return null; + } + + public static String keyForIconView(View view) { + if (view == null) { + return null; + } + Object methodValue = ReflectionSupport.invokeMethod(view, "getNotificationKey", new Class[0]); + if (methodValue instanceof String string && !string.isEmpty()) { + return string; + } + for (String fieldName : new String[]{"mNotificationKey", "notificationKey", "mKey", "key"}) { + Object value = ReflectionSupport.getFieldValue(view, fieldName); + if (value instanceof String string && !string.isEmpty()) { + return string; + } + } + return null; + } + + private static int visibleCardCountBeforeShelf(View root, View shelf) { + if (!(root instanceof ViewGroup group)) { + return 0; + } + 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; + } + } + 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; + } + + private static boolean isNotificationRow(View view) { + if (view == null) { + return false; + } + String className = view.getClass().getName(); + return className.contains("ExpandableNotificationRow"); + } + + private static String resolveIdName(View view) { + if (view == null || view.getId() == View.NO_ID) { + return ""; + } + try { + return view.getResources().getResourceEntryName(view.getId()); + } catch (Throwable ignored) { + return ""; + } + } + + private static boolean isVisibleCardRow(View view) { + if (view == null || view.getVisibility() == View.GONE) { + return false; + } + if (!view.isShown() || effectiveAlpha(view) <= 0.05f) { + return false; + } + if (ReflectionSupport.getBooleanField(view, "mInShelf", false) + || ReflectionSupport.getBooleanField(view, "mTransformingInShelf", false)) { + return false; + } + Object viewState = ReflectionSupport.getFieldValue(view, "mViewState"); + if (viewState != null + && (ReflectionSupport.getBooleanField(viewState, "hidden", false) + || ReflectionSupport.getBooleanField(viewState, "gone", false))) { + return false; + } + int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth(); + int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight(); + return width > 0 && height > 0 && actualHeight(view, height) > 0; + } + + private static int actualHeight(View view, int fallback) { + Object methodValue = ReflectionSupport.invokeMethod(view, "getActualHeight", new Class[0]); + if (methodValue instanceof Integer integer && integer > 0) { + return integer; + } + Object fieldValue = ReflectionSupport.getFieldValue(view, "mActualHeight"); + if (fieldValue instanceof Integer integer && integer > 0) { + return integer; + } + return fallback; + } + + private static float effectiveAlpha(View view) { + float alpha = 1f; + Object current = view; + while (current instanceof View currentView) { + alpha *= currentView.getAlpha(); + current = currentView.getParent(); + } + return alpha; + } + + public static final class SourceEntry { + final View view; + final String key; + + private SourceEntry(View view, String key) { + this.view = view; + this.key = key; + } + } +} 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 new file mode 100644 index 0000000..9464e90 --- /dev/null +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/LockscreenCardsNotificationStripRenderer.java @@ -0,0 +1,536 @@ +package se.ajpanton.statusbartweak.runtime.render; + +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.WeakHashMap; + +import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds; +import se.ajpanton.statusbartweak.runtime.mode.NotificationDisplayStyle; +import se.ajpanton.statusbartweak.runtime.mode.SceneKey; +import se.ajpanton.statusbartweak.shell.settings.SbtSettings; + +/** + * Mirrors Samsung's cards-mode shelf/icon-only notification strip into an owned + * host. The stock shelf already represents notifications not currently shown as + * cards, so this renderer treats it as the source list and only owns placement. + */ +public final class LockscreenCardsNotificationStripRenderer { + private static final int DEFAULT_PILL_COLOR = 0xAA202124; + private static final String PILL_BACKGROUND_TAG = "statusbartweak.cards.notification.pill"; + + private final SnapshotRenderer renderer = new SnapshotRenderer(false, false, false); + private final WeakHashMap> pillViewsByHost = new WeakHashMap<>(); + + public RenderResult render( + View root, + ViewGroup host, + SbtSettings settings, + SceneKey scene + ) { + if (root == null + || host == null + || settings == null + || scene == null + || !scene.isLockscreen() + || scene.notificationDisplayStyle() != NotificationDisplayStyle.CARDS + || settings.cardsLockMaxRows <= 0) { + clear(host); + return RenderResult.empty(); + } + ViewGroup sourceRoot = findSourceRoot(root); + if (sourceRoot == null) { + clear(host); + return RenderResult.empty(); + } + if (hasHiddenShelfAncestor(sourceRoot, root)) { + clear(host); + return RenderResult.empty(); + } + syncHostAnimation(host, sourceRoot); + ArrayList sources = LockscreenCardsNotificationIconSourceStore.snapshotSources(sourceRoot, root); + if (sources.isEmpty()) { + sources = collectSources(sourceRoot); + } + if (sources.isEmpty()) { + clear(host); + return RenderResult.empty(); + } + ArrayList placements = SnapshotIconFilter.notificationPlacements( + renderer.rawPlacements(root, sources), + settings, + scene); + StripLayout layout = layoutPlacements(root, sourceRoot, placements, settings); + renderPills(host, layout.rowBounds, resolvePillColor(sourceRoot)); + Bounds renderedBounds = renderer.renderPlacements(host, layout.placements); + return new RenderResult(sourceRoot, renderedBounds); + } + + public void clear(ViewGroup host) { + renderer.clear(host); + clearPills(host); + } + + public void clearAll() { + renderer.clearAll(); + } + + private ViewGroup findSourceRoot(View root) { + if (!(root instanceof ViewGroup group)) { + return null; + } + ArrayDeque queue = new ArrayDeque<>(); + queue.add(group); + ViewGroup shelf = null; + ViewGroup iconOnly = null; + while (!queue.isEmpty()) { + View view = queue.removeFirst(); + if (view instanceof ViewGroup candidate) { + if (isShelfIconContainer(candidate)) { + shelf = candidate; + } else if (isIconOnlyContainer(candidate) && iconOnly == null) { + iconOnly = candidate; + } + for (int i = 0; i < candidate.getChildCount(); i++) { + View child = candidate.getChildAt(i); + if (child != null) { + queue.addLast(child); + } + } + } + } + return shelf != null ? shelf : iconOnly; + } + + private boolean hasHiddenShelfAncestor(View sourceRoot, View root) { + Object current = sourceRoot != null ? sourceRoot.getParent() : null; + while (current instanceof View currentView) { + if (currentView == root) { + return false; + } + if (currentView.getClass().getName().contains("NotificationShelf")) { + return currentView.getVisibility() != View.VISIBLE || currentView.getAlpha() <= 0f; + } + current = currentView.getParent(); + } + return false; + } + + private void syncHostAnimation(ViewGroup host, View sourceRoot) { + if (host == null || sourceRoot == null) { + return; + } + float alpha = effectiveAncestorAlpha(sourceRoot); + if (host.getAlpha() != alpha) { + host.setAlpha(alpha); + } + } + + private float effectiveAncestorAlpha(View view) { + float alpha = 1f; + Object current = view.getParent(); + while (current instanceof View currentView) { + alpha *= currentView.getAlpha(); + current = currentView.getParent(); + } + return Math.max(0f, Math.min(1f, alpha)); + } + + private ArrayList collectSources(ViewGroup sourceRoot) { + ArrayList result = new ArrayList<>(); + ArrayDeque queue = new ArrayDeque<>(); + queue.add(sourceRoot); + while (!queue.isEmpty()) { + View view = queue.removeFirst(); + if (view != sourceRoot && isNotificationIconCandidate(view)) { + result.add(new SnapshotSource(view, SnapshotMode.NOTIFICATION_DRAWABLE)); + continue; + } + if (view instanceof ViewGroup group) { + for (int i = 0; i < group.getChildCount(); i++) { + View child = group.getChildAt(i); + if (child != null) { + queue.addLast(child); + } + } + } + } + return result; + } + + private StripLayout layoutPlacements( + View root, + View sourceRoot, + ArrayList placements, + SbtSettings settings + ) { + ArrayList result = new ArrayList<>(); + ArrayList rowBounds = new ArrayList<>(); + if (placements == null || placements.isEmpty()) { + return new StripLayout(result, rowBounds); + } + int maxPerRow = Math.max(1, settings.cardsLockMaxIconsPerRow); + int maxRows = Math.max(0, settings.cardsLockMaxRows); + int capacity = maxPerRow * maxRows; + if (capacity <= 0) { + return new StripLayout(result, rowBounds); + } + boolean showDot = placements.size() > capacity; + int iconSlots = showDot ? Math.max(0, capacity - 1) : Math.min(capacity, placements.size()); + int visible = iconSlots + (showDot ? 1 : 0); + int iconHeight = representativeHeight(placements); + int iconWidth = representativeWidth(placements, iconHeight); + Bounds sourceBounds = ViewGeometry.boundsRelativeTo(root, sourceRoot); + int top = sourceBounds != null ? sourceBounds.top : placements.get(0).bounds.top; + int centerX = sourceBounds != null + ? sourceBounds.left + sourceBounds.width / 2 + : root.getWidth() / 2; + int availableWidth = settings.cardsLockLimitToWidth + ? Math.max(0, root.getWidth()) + : Integer.MAX_VALUE; + int index = 0; + int[] rowCounts = rowCounts(visible, maxPerRow, maxRows, settings.cardsLockEvenDistribution); + int dotColor = dotColor(placements); + int rowGap = dp(root, 8); + for (int row = 0; row < rowCounts.length && index < visible; row++) { + int count = rowCounts[row]; + if (count <= 0) { + continue; + } + int rowWidth = count * iconWidth; + int left = centerX - rowWidth / 2; + if (availableWidth != Integer.MAX_VALUE) { + left = Math.max(0, Math.min(left, Math.max(0, availableWidth - rowWidth))); + } + int y = top + row * (iconHeight + rowGap); + rowBounds.add(new Bounds(left, y, rowWidth, iconHeight)); + for (int col = 0; col < count && index < visible; col++, index++) { + boolean dotSlot = showDot && index == visible - 1; + SnapshotPlacement placement = dotSlot + ? dotPlacement(iconWidth, iconHeight, dotColor) + : placements.get(index); + result.add(new SnapshotPlacement( + placement.source, + new Bounds(left + col * iconWidth, y, iconWidth, iconHeight), + placement.key, + placement.signal, + placement.overflowDot, + placement.overflowDotColor, + placement.heldSignal, + new Bounds(0, 0, iconWidth, iconHeight), + 0)); + } + } + return new StripLayout(result, rowBounds); + } + + private int[] rowCounts( + int slots, + int maxPerRow, + int maxRows, + boolean evenDistribution + ) { + if (slots <= 0 || maxPerRow <= 0 || maxRows <= 0) { + return new int[0]; + } + int rowCount = Math.min(maxRows, (slots + maxPerRow - 1) / maxPerRow); + int[] result = new int[rowCount]; + if (evenDistribution && rowCount > 1) { + int base = slots / rowCount; + int remainder = slots % rowCount; + for (int row = 0; row < rowCount; row++) { + result[row] = base + (row < remainder ? 1 : 0); + } + return result; + } + int remaining = slots; + for (int row = 0; row < rowCount; row++) { + result[row] = Math.min(maxPerRow, remaining); + remaining -= result[row]; + } + return result; + } + + private SnapshotPlacement dotPlacement(int width, int height, int color) { + return new SnapshotPlacement( + null, + new Bounds(0, 0, Math.max(1, width), Math.max(1, height)), + SnapshotKeys.OVERFLOW_DOT + ":cards_notifications", + false, + true, + color, + null, + new Bounds(0, 0, Math.max(1, width), Math.max(1, height)), + 0); + } + + private int dotColor(ArrayList placements) { + if (placements != null) { + for (SnapshotPlacement placement : placements) { + if (placement == null || placement.source == null) { + continue; + } + int color = SnapshotAppearanceSignature.preferredColor(placement.source.view(), Color.TRANSPARENT); + if (Color.alpha(color) != 0) { + return color; + } + } + } + return Color.WHITE; + } + + private void renderPills(ViewGroup host, ArrayList rowBounds, int color) { + if (host == null || rowBounds == null || rowBounds.isEmpty()) { + clearPills(host); + return; + } + ArrayList pills = pillViewsByHost.get(host); + if (pills == null) { + pills = new ArrayList<>(); + pillViewsByHost.put(host, pills); + } + while (pills.size() < rowBounds.size()) { + View pill = new View(host.getContext()); + pill.setTag(PILL_BACKGROUND_TAG); + pill.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); + pills.add(pill); + } + int horizontalPadding = dp(host, 8); + int verticalPadding = dp(host, 3); + for (int i = 0; i < pills.size(); i++) { + View pill = pills.get(i); + if (i >= rowBounds.size()) { + if (pill.getParent() == host) { + host.removeView(pill); + } + continue; + } + Bounds row = rowBounds.get(i); + int left = row.left - horizontalPadding; + int top = row.top - verticalPadding; + int width = Math.max(1, row.width + horizontalPadding * 2); + int height = Math.max(1, row.height + verticalPadding * 2); + GradientDrawable background = new GradientDrawable(); + background.setColor(color); + background.setCornerRadius(height / 2f); + pill.setBackground(background); + if (pill.getParent() != host) { + host.addView(pill, Math.min(i, host.getChildCount())); + } else { + host.removeView(pill); + host.addView(pill, Math.min(i, host.getChildCount())); + } + if (applyPillLayoutParamsIfChanged(host, pill, left, top, width, height)) { + host.requestLayout(); + } + if (pill.getVisibility() != View.VISIBLE) { + pill.setVisibility(View.VISIBLE); + } + if (pill.getAlpha() != 1f) { + pill.setAlpha(1f); + } + if (pill.getLeft() != left + || pill.getTop() != top + || pill.getRight() != left + width + || pill.getBottom() != top + height) { + pill.layout(left, top, left + width, top + height); + } + } + host.invalidate(); + } + + private void clearPills(ViewGroup host) { + if (host == null) { + return; + } + ArrayList pills = pillViewsByHost.remove(host); + if (pills == null) { + return; + } + for (View pill : pills) { + if (pill != null && pill.getParent() == host) { + host.removeView(pill); + } + } + host.invalidate(); + } + + private int resolvePillColor(View sourceRoot) { + View backgroundSource = findShelfBackground(sourceRoot); + int color = backgroundColor(backgroundSource); + if (Color.alpha(color) != 0) { + return color; + } + color = backgroundColor(sourceRoot); + return Color.alpha(color) != 0 ? color : DEFAULT_PILL_COLOR; + } + + private View findShelfBackground(View root) { + if (!(root instanceof ViewGroup group)) { + return null; + } + ArrayDeque queue = new ArrayDeque<>(); + queue.add(group); + while (!queue.isEmpty()) { + View view = queue.removeFirst(); + String className = view.getClass().getName(); + String idName = resolveIdName(view); + if (className.contains("NotificationShelfBackground") + || idName.toLowerCase(java.util.Locale.ROOT).contains("background")) { + return view; + } + if (view instanceof ViewGroup childGroup) { + for (int i = 0; i < childGroup.getChildCount(); i++) { + View child = childGroup.getChildAt(i); + if (child != null) { + queue.addLast(child); + } + } + } + } + return null; + } + + private int backgroundColor(View view) { + if (view == null) { + return Color.TRANSPARENT; + } + Drawable background = view.getBackground(); + if (background instanceof ColorDrawable colorDrawable) { + return colorDrawable.getColor(); + } + return Color.TRANSPARENT; + } + + private int dp(View view, int value) { + float density = view.getResources().getDisplayMetrics().density; + return Math.max(0, Math.round(value * density)); + } + + private boolean applyPillLayoutParamsIfChanged( + ViewGroup host, + View pill, + int left, + int top, + int width, + int height + ) { + ViewGroup.LayoutParams current = pill.getLayoutParams(); + if (current != null + && current.width == width + && current.height == height + && current instanceof ViewGroup.MarginLayoutParams marginParams + && marginParams.leftMargin == left + && marginParams.topMargin == top) { + return false; + } + pill.setLayoutParams(layoutParamsFor(host, left, top, width, height)); + return true; + } + + private ViewGroup.LayoutParams layoutParamsFor( + ViewGroup host, + int left, + int top, + int width, + int height + ) { + if (host instanceof FrameLayout) { + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(width, height); + params.leftMargin = left; + params.topMargin = top; + return params; + } + ViewGroup.MarginLayoutParams params = new ViewGroup.MarginLayoutParams(width, height); + params.leftMargin = left; + params.topMargin = top; + return params; + } + + private int representativeWidth(ArrayList placements, int fallback) { + for (SnapshotPlacement placement : placements) { + if (placement != null && placement.bounds != null && placement.bounds.width > 0) { + return placement.bounds.width; + } + } + return Math.max(1, fallback); + } + + private int representativeHeight(ArrayList placements) { + for (SnapshotPlacement placement : placements) { + if (placement != null && placement.bounds != null && placement.bounds.height > 0) { + return placement.bounds.height; + } + } + return 1; + } + + private boolean isShelfIconContainer(View view) { + String className = view.getClass().getName(); + return className.contains("SecShelfNotificationIconContainer"); + } + + private boolean isIconOnlyContainer(View view) { + String idName = resolveIdName(view); + return "keyguard_icononly_container_view".equals(idName) + || "notification_icononly_container".equals(idName) + || "notification_icononly_area".equals(idName) + || "common_notification_widget".equals(idName); + } + + private boolean isNotificationIconCandidate(View view) { + if (view == null || view instanceof TextView || view.getVisibility() != View.VISIBLE) { + return false; + } + int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth(); + int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight(); + if (width <= 0 || height <= 0 || isOverflowDotCandidate(view)) { + return false; + } + String className = view.getClass().getName(); + return className.contains("StatusBarIconView") + || className.contains("IconView") + || view instanceof ImageView; + } + + private boolean isOverflowDotCandidate(View view) { + String idName = resolveIdName(view).toLowerCase(java.util.Locale.ROOT); + String className = view.getClass().getName().toLowerCase(java.util.Locale.ROOT); + return idName.contains("overflow") + || idName.contains("dot") + || className.contains("overflow") + || className.contains("dot"); + } + + private String resolveIdName(View view) { + if (view == null || view.getId() == View.NO_ID) { + return ""; + } + try { + return view.getResources().getResourceEntryName(view.getId()); + } catch (Throwable ignored) { + return ""; + } + } + + public record RenderResult(View sourceRoot, Bounds renderedBounds) { + static RenderResult empty() { + return new RenderResult(null, null); + } + } + + private record StripLayout( + ArrayList placements, + ArrayList rowBounds + ) { + } +} 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 ea026ca..aa9a789 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 @@ -24,6 +24,8 @@ public final class OwnedIconHostManager { "statusbartweak.unlocked.carrier.host"; public static final String TAG_UNLOCKED_CHIP_HOST = "statusbartweak.unlocked.chip.host"; + public static final String TAG_LOCKSCREEN_CARDS_NOTIFICATION_HOST = + "statusbartweak.lockscreen.cards.notification.host"; public FrameLayout ensureUnlockedStatusHost(View root) { return ensureHost(root, TAG_UNLOCKED_STATUS_HOST); @@ -45,6 +47,10 @@ public final class OwnedIconHostManager { return ensureHost(root, TAG_UNLOCKED_CARRIER_HOST); } + public FrameLayout ensureLockscreenCardsNotificationHost(View root) { + return ensureHost(root, TAG_LOCKSCREEN_CARDS_NOTIFICATION_HOST); + } + public FrameLayout ensureHost(View root, String tag) { Objects.requireNonNull(tag, "tag"); if (!(root instanceof ViewGroup parent)) { @@ -64,7 +70,6 @@ public final class OwnedIconHostManager { parent.addView(host, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); - disableAncestorClipping(parent); host.bringToFront(); return host; } @@ -85,6 +90,7 @@ public final class OwnedIconHostManager { removeHost(root, TAG_UNLOCKED_CLOCK_HOST); removeHost(root, TAG_UNLOCKED_CARRIER_HOST); removeHost(root, TAG_UNLOCKED_CHIP_HOST); + removeHost(root, TAG_LOCKSCREEN_CARDS_NOTIFICATION_HOST); } public void setUnlockedHostsVisible(View root, boolean visible) { @@ -112,7 +118,8 @@ public final class OwnedIconHostManager { || TAG_UNLOCKED_NOTIFICATION_HOST.equals(tag) || TAG_UNLOCKED_CLOCK_HOST.equals(tag) || TAG_UNLOCKED_CARRIER_HOST.equals(tag) - || TAG_UNLOCKED_CHIP_HOST.equals(tag); + || TAG_UNLOCKED_CHIP_HOST.equals(tag) + || TAG_LOCKSCREEN_CARDS_NOTIFICATION_HOST.equals(tag); } private FrameLayout findDirectHost(ViewGroup parent, String tag) { @@ -146,15 +153,4 @@ public final class OwnedIconHostManager { } } - private void disableAncestorClipping(View view) { - Object current = view; - int depth = 0; - while (current instanceof ViewGroup group && depth < 8) { - group.setClipChildren(false); - group.setClipToPadding(false); - group.setClipToOutline(false); - current = group.getParent(); - depth++; - } - } } diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/SnapshotIconFilter.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/SnapshotIconFilter.java new file mode 100644 index 0000000..f7e6b2b --- /dev/null +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/SnapshotIconFilter.java @@ -0,0 +1,278 @@ +package se.ajpanton.statusbartweak.runtime.render; + +import android.content.res.Resources; +import android.view.View; + +import java.util.ArrayList; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import se.ajpanton.statusbartweak.runtime.mode.SceneKey; +import se.ajpanton.statusbartweak.shell.settings.AppIconRules; +import se.ajpanton.statusbartweak.shell.settings.SbtSettings; +import se.ajpanton.statusbartweak.shell.settings.SystemIconRules; + +final class SnapshotIconFilter { + private SnapshotIconFilter() { + } + + static ArrayList statusPlacements( + ArrayList placements, + SbtSettings settings, + SceneKey scene + ) { + String mode = modeName(scene); + if (placements == null + || placements.isEmpty() + || settings == null + || settings.systemIconBlockedModes.isEmpty() + || mode == null) { + return placements != null ? placements : new ArrayList<>(); + } + ArrayList filtered = new ArrayList<>(placements.size()); + for (SnapshotPlacement placement : placements) { + if (!isStatusBlocked(placement, settings.systemIconBlockedModes, mode)) { + filtered.add(placement); + } + } + return filtered; + } + + static ArrayList notificationPlacements( + ArrayList placements, + SbtSettings settings, + SceneKey scene + ) { + String mode = modeName(scene); + if (placements == null + || placements.isEmpty() + || settings == null + || mode == null) { + return placements != null ? placements : new ArrayList<>(); + } + ArrayList filtered = new ArrayList<>(placements.size()); + for (SnapshotPlacement placement : placements) { + String pkg = notificationPackage(placement != null ? placement.key : null); + if (pkg == null + || !isNotificationBlocked( + placement, + pkg, + settings.appIconBlockedModes, + settings.appIconExceptionRules, + mode)) { + filtered.add(placement); + } + } + return filtered; + } + + static String notificationPackage(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; + } + + static boolean isNotificationBlocked( + SnapshotPlacement placement, + String pkg, + Map blockedModes, + Map> exceptionRules, + String mode + ) { + if (!AppIconRules.isValidPackageName(pkg) || mode == null) { + return false; + } + AppIconRules.ExceptionRule matchingRule = matchingExceptionRule( + placement, + exceptionRules != null ? exceptionRules.get(pkg) : null); + if (matchingRule != null) { + return matchingRule.blocksMode(mode); + } + return AppIconRules.isBlocked(blockedModes, pkg, mode); + } + + static boolean isStatusBlocked( + SnapshotPlacement placement, + Map blockedModes, + String mode + ) { + if (placement == null || blockedModes == null || blockedModes.isEmpty() || mode == null) { + return false; + } + String text = statusMatchText(placement); + for (String slot : statusSlots(text)) { + if (SystemIconRules.isBlocked(blockedModes, slot, mode)) { + return true; + } + } + return false; + } + + private static AppIconRules.ExceptionRule matchingExceptionRule( + SnapshotPlacement placement, + ArrayList rules + ) { + if (rules == null || rules.isEmpty()) { + return null; + } + String text = notificationMatchText(placement); + for (AppIconRules.ExceptionRule rule : rules) { + if (rule == null || rule.regex == null || rule.regex.trim().isEmpty()) { + continue; + } + try { + if (Pattern.compile(rule.regex).matcher(text).find()) { + return rule; + } + } catch (PatternSyntaxException ignored) { + // Invalid user regexes are ignored instead of blocking all icons. + } + } + return null; + } + + private static String notificationMatchText(SnapshotPlacement placement) { + StringBuilder sb = new StringBuilder(); + append(sb, placement != null ? placement.key : null); + View view = placement != null && placement.source != null ? placement.source.view() : null; + appendViewText(sb, view); + return sb.toString(); + } + + private static String statusMatchText(SnapshotPlacement placement) { + StringBuilder sb = new StringBuilder(); + append(sb, placement.key); + View view = placement.source != null ? placement.source.view() : null; + appendViewText(sb, view); + return sb.toString().toLowerCase(Locale.ROOT); + } + + private static void appendViewText(StringBuilder sb, View view) { + if (view == null) { + return; + } + append(sb, view.getClass().getName()); + append(sb, resolveIdName(view)); + CharSequence contentDescription = view.getContentDescription(); + append(sb, contentDescription != null ? contentDescription.toString() : null); + } + + private static ArrayList statusSlots(String matchText) { + ArrayList slots = new ArrayList<>(); + add(slots, normalizedSlot(matchText)); + if (matchText.contains("signal:wifi") || matchText.contains("wifi")) { + add(slots, "wifi"); + } + if (matchText.contains("signal:mobile") || matchText.contains("mobile")) { + add(slots, "mobile"); + } + if (matchText.contains("battery")) { + add(slots, "battery"); + add(slots, "battery_icon"); + } + if (matchText.contains("zen") || matchText.contains("dnd") || matchText.contains("disturb")) { + add(slots, "zen"); + add(slots, "dnd"); + add(slots, "donotdisturb"); + } + if (matchText.contains("volume") || matchText.contains("mute")) { + add(slots, "volume"); + add(slots, "mute"); + } + if (matchText.contains("bluetooth")) { + add(slots, "bluetooth"); + add(slots, "bluetooth_connected"); + } + if (matchText.contains("hotspot")) { + add(slots, "hotspot"); + } + if (matchText.contains("alarm")) { + add(slots, "alarm_clock"); + } + if (matchText.contains("airplane")) { + add(slots, "airplane"); + add(slots, "stat_sys_airplane_mode"); + } + if (matchText.contains("vpn")) { + add(slots, "vpn"); + } + if (matchText.contains("nfc")) { + add(slots, "nfc"); + add(slots, "nfc_on"); + } + if (matchText.contains("data_saver")) { + add(slots, "data_saver"); + } + if (matchText.contains("location")) { + add(slots, "location"); + } + return slots; + } + + private static String normalizedSlot(String value) { + if (value == null) { + return ""; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + if (normalized.startsWith("signal:")) { + normalized = normalized.substring("signal:".length()); + } + return normalized; + } + + private static String modeName(SceneKey scene) { + if (scene == null) { + return null; + } + if (scene.isAod()) { + return "AOD"; + } + if (scene.isLockscreen()) { + return "LOCK"; + } + if (scene.isUnlocked()) { + return "UNLOCK"; + } + return null; + } + + private static void add(ArrayList values, String value) { + if (value == null || value.isEmpty() || values.contains(value)) { + return; + } + values.add(value); + } + + private static void append(StringBuilder sb, String value) { + if (value == null || value.isEmpty()) { + return; + } + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(value); + } + + private static String resolveIdName(View view) { + if (view == null || view.getId() == View.NO_ID) { + return ""; + } + try { + return view.getResources().getResourceEntryName(view.getId()); + } catch (Resources.NotFoundException ignored) { + return ""; + } + } +} diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/UnlockedIconSnapshotRenderController.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/UnlockedIconSnapshotRenderController.java index 0ec09d1..46443ae 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/UnlockedIconSnapshotRenderController.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/UnlockedIconSnapshotRenderController.java @@ -265,6 +265,47 @@ public final class UnlockedIconSnapshotRenderController { } } + public boolean refreshUnlockedClockText( + View root, + ViewGroup clockHost, + SceneKey scene + ) { + if (root == null + || clockHost == null + || scene == null + || !scene.isUnlocked() + || !root.isAttachedToWindow()) { + return false; + } + LayoutPlan layoutPlan = lastLayoutPlanByRoot.get(root); + if (layoutPlan == null || layoutPlan.clockPlacement == null) { + return false; + } + SbtSettings settings = RuntimeSettingsCache.get(root.getContext()); + if (settings == null || !settings.clockEnabledUnlocked) { + return false; + } + CollectedIcons collectedIcons = lastCollectedIconsByRoot.get(root); + int referenceColor = clockReferenceColor(layoutPlan.clockPlacement, collectedIcons); + ClockPlacement updatedClock = clockPlacementWithTextFromModel( + layoutPlan.clockPlacement, + layoutPlan.clockPlacement.source, + settings, + referenceColor); + if (updatedClock == null) { + return false; + } + LayoutPlan updatedPlan = layoutPlan.withClockPlacement(updatedClock); + lastLayoutPlanByRoot.put(root, updatedPlan); + lastClockAppearanceByRoot.put(root, clockAppearanceSignature(updatedClock, referenceColor, settings)); + clockRenderer.render(clockHost, updatedClock); + return true; + } + + public boolean hasLayoutPlan(View root) { + return root != null && lastLayoutPlanByRoot.containsKey(root); + } + private void clearLockscreenHosts( ViewGroup clockHost, ViewGroup carrierHost, 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 df62cb6..05dac57 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 @@ -96,7 +96,10 @@ final class UnlockedLayoutPlanner { int statusCount = statusCountForScene(settings, scene); if (statusCount > 0) { ArrayList rawPlacements = - statusRenderer.rawPlacements(root, collectedIcons.statusIcons); + SnapshotIconFilter.statusPlacements( + statusRenderer.rawPlacements(root, collectedIcons.statusIcons), + settings, + scene); int representativeHeight = representativeHeight(rawPlacements); String[] positions = statusPositionsForScene(settings, scene); String[] middleSides = statusMiddleSidesForScene(settings, scene); @@ -128,7 +131,10 @@ final class UnlockedLayoutPlanner { int notificationCount = notificationCountForScene(settings, scene); if (notificationCount > 0) { ArrayList rawPlacements = - notificationRenderer.rawPlacements(root, collectedIcons.notificationIcons); + SnapshotIconFilter.notificationPlacements( + notificationRenderer.rawPlacements(root, collectedIcons.notificationIcons), + settings, + scene); if (scene != null && scene.isLockscreen() && scene.notificationDisplayStyle() == NotificationDisplayStyle.DOT) { diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/UnlockedStockSourceCollector.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/UnlockedStockSourceCollector.java index 11c29ad..c840299 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/UnlockedStockSourceCollector.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/UnlockedStockSourceCollector.java @@ -120,8 +120,8 @@ final class UnlockedStockSourceCollector { anchors != null ? anchors.clockContainer : null), notificationRootSignature(anchors != null ? anchors.notificationRoot : null), viewSignatureOrZero(anchors != null ? anchors.carrierView : null), - sourceListSignature(previousIcons != null ? previousIcons.statusIcons : null), - sourceListSignature(previousIcons != null ? previousIcons.notificationIcons : null), + sourceLayoutListSignature(previousIcons != null ? previousIcons.statusIcons : null), + sourceLayoutListSignature(previousIcons != null ? previousIcons.notificationIcons : null), chipSourceSignature); } @@ -466,20 +466,6 @@ final class UnlockedStockSourceCollector { return false; } - private static int viewTreeSignature(View view) { - if (view == null) { - return 0; - } - int result = viewSignature(view); - if (view instanceof ViewGroup group) { - result = 31 * result + group.getChildCount(); - for (int i = 0; i < group.getChildCount(); i++) { - result = 31 * result + viewTreeSignature(group.getChildAt(i)); - } - } - return result; - } - private static int viewSignatureOrZero(View view) { return view != null ? viewSignature(view) : 0; } @@ -520,7 +506,6 @@ final class UnlockedStockSourceCollector { result = 31 * result + view.getClass().getName().hashCode(); result = 31 * result + view.getId(); result = 31 * result + view.getVisibility(); - result = 31 * result + Float.floatToIntBits(view.getAlpha()); if (view instanceof ViewGroup group) { result = 31 * result + group.getChildCount(); for (int i = 0; i < group.getChildCount(); i++) { @@ -529,13 +514,12 @@ final class UnlockedStockSourceCollector { result = 31 * result + child.getClass().getName().hashCode(); result = 31 * result + child.getId(); result = 31 * result + child.getVisibility(); - result = 31 * result + Float.floatToIntBits(child.getAlpha()); } } return result; } - private static int sourceListSignature(ArrayList sources) { + private static int sourceLayoutListSignature(ArrayList sources) { if (sources == null || sources.isEmpty()) { return 0; } @@ -543,7 +527,34 @@ final class UnlockedStockSourceCollector { result = 31 * result + sources.size(); for (SnapshotSource source : sources) { result = 31 * result + source.mode().ordinal(); - result = 31 * result + viewTreeSignature(source.view()); + result = 31 * result + sourceLayoutSignature(source.view()); + } + return result; + } + + private static int sourceLayoutSignature(View view) { + if (view == null) { + return 0; + } + int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth(); + int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight(); + int result = 17; + result = 31 * result + System.identityHashCode(view); + result = 31 * result + view.getClass().getName().hashCode(); + result = 31 * result + view.getId(); + result = 31 * result + view.getVisibility(); + result = 31 * result + width; + result = 31 * result + height; + CharSequence contentDescription = view.getContentDescription(); + result = 31 * result + (contentDescription != null ? contentDescription.toString().hashCode() : 0); + if (view instanceof ImageView imageView) { + result = 31 * result + drawableLayoutSignature(imageView.getDrawable()); + } + if (view instanceof ViewGroup group) { + result = 31 * result + group.getChildCount(); + for (int i = 0; i < group.getChildCount(); i++) { + result = 31 * result + sourceLayoutSignature(group.getChildAt(i)); + } } return result; } @@ -560,6 +571,9 @@ final class UnlockedStockSourceCollector { hasKnownChipSource = true; result = 31 * result + chipSourceListSignature(previousIcons.statusChipMetrics); } + if (!hasKnownChipSource) { + return 0; + } } if (!hasKnownChipSource) { result = 31 * result + statusChipCandidateSignature(root); @@ -780,6 +794,9 @@ final class UnlockedStockSourceCollector { settings.layoutStatusMiddleSide, settings.layoutStatusMiddleAutoNearestSide, settings.layoutStatusVerticalOffsetPx, + settings.systemIconBlockedModes, + settings.appIconBlockedModes, + settings.appIconExceptionRules, settings.statusChipsHideAll, settings.statusChipsHideMediaUnlocked, settings.statusChipsHideNavUnlocked, 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 218182b..a5d7db4 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 @@ -1417,8 +1417,10 @@ public final class SbtSettings { String fallback ) { String[] result = new String[SbtDefaults.LAYOUT_ICON_CONTAINER_COUNT_MAX]; + String previous = fallback; for (int i = 0; i < result.length; i++) { - result[i] = prefs.getString(key.key(i), fallback); + previous = prefs.contains(key.key(i)) ? prefs.getString(key.key(i), previous) : previous; + result[i] = previous; } return result; } @@ -1429,40 +1431,50 @@ public final class SbtSettings { boolean fallback ) { boolean[] result = new boolean[SbtDefaults.LAYOUT_ICON_CONTAINER_COUNT_MAX]; + boolean previous = fallback; for (int i = 0; i < result.length; i++) { - result[i] = prefs.getBoolean(key.key(i), fallback); + previous = prefs.contains(key.key(i)) ? prefs.getBoolean(key.key(i), previous) : previous; + result[i] = previous; } return result; } private static int[] readIntArrayFromPrefs(SharedPreferences prefs, IndexedKey key, int fallback) { int[] result = new int[SbtDefaults.LAYOUT_ICON_CONTAINER_COUNT_MAX]; + int previous = fallback; for (int i = 0; i < result.length; i++) { - result[i] = prefs.getInt(key.key(i), fallback); + previous = prefs.contains(key.key(i)) ? prefs.getInt(key.key(i), previous) : previous; + result[i] = previous; } return result; } private static String[] readStringArrayFromBundle(Bundle bundle, IndexedKey key, String fallback) { String[] result = new String[SbtDefaults.LAYOUT_ICON_CONTAINER_COUNT_MAX]; + String previous = fallback; for (int i = 0; i < result.length; i++) { - result[i] = bundle.getString(key.key(i), fallback); + previous = bundle.containsKey(key.key(i)) ? bundle.getString(key.key(i), previous) : previous; + result[i] = previous; } return result; } private static boolean[] readBooleanArrayFromBundle(Bundle bundle, IndexedKey key, boolean fallback) { boolean[] result = new boolean[SbtDefaults.LAYOUT_ICON_CONTAINER_COUNT_MAX]; + boolean previous = fallback; for (int i = 0; i < result.length; i++) { - result[i] = bundle.getBoolean(key.key(i), fallback); + previous = bundle.containsKey(key.key(i)) ? bundle.getBoolean(key.key(i), previous) : previous; + result[i] = previous; } return result; } private static int[] readIntArrayFromBundle(Bundle bundle, IndexedKey key, int fallback) { int[] result = new int[SbtDefaults.LAYOUT_ICON_CONTAINER_COUNT_MAX]; + int previous = fallback; for (int i = 0; i < result.length; i++) { - result[i] = bundle.getInt(key.key(i), fallback); + previous = bundle.containsKey(key.key(i)) ? bundle.getInt(key.key(i), previous) : previous; + result[i] = previous; } return result; } diff --git a/app/src/main/java/se/ajpanton/statusbartweak/shell/settings/SbtSettingsProvider.java b/app/src/main/java/se/ajpanton/statusbartweak/shell/settings/SbtSettingsProvider.java index 318feb9..c1e03be 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/shell/settings/SbtSettingsProvider.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/shell/settings/SbtSettingsProvider.java @@ -411,11 +411,27 @@ public class SbtSettingsProvider extends ContentProvider { IndexedKey verticalOffsetKey, int verticalOffsetDefault ) { + String position = positionDefault; + String middleSide = middleSideDefault; + boolean autoNearest = autoNearestDefault; + int verticalOffset = verticalOffsetDefault; for (int i = 0; i < SbtDefaults.LAYOUT_ICON_CONTAINER_COUNT_MAX; i++) { - out.putString(positionKey.key(i), prefs.getString(positionKey.key(i), positionDefault)); - out.putString(middleSideKey.key(i), prefs.getString(middleSideKey.key(i), middleSideDefault)); - out.putBoolean(autoNearestKey.key(i), prefs.getBoolean(autoNearestKey.key(i), autoNearestDefault)); - out.putInt(verticalOffsetKey.key(i), prefs.getInt(verticalOffsetKey.key(i), verticalOffsetDefault)); + position = prefs.contains(positionKey.key(i)) + ? prefs.getString(positionKey.key(i), position) + : position; + middleSide = prefs.contains(middleSideKey.key(i)) + ? prefs.getString(middleSideKey.key(i), middleSide) + : middleSide; + autoNearest = prefs.contains(autoNearestKey.key(i)) + ? prefs.getBoolean(autoNearestKey.key(i), autoNearest) + : autoNearest; + verticalOffset = prefs.contains(verticalOffsetKey.key(i)) + ? prefs.getInt(verticalOffsetKey.key(i), verticalOffset) + : verticalOffset; + out.putString(positionKey.key(i), position); + out.putString(middleSideKey.key(i), middleSide); + out.putBoolean(autoNearestKey.key(i), autoNearest); + out.putInt(verticalOffsetKey.key(i), verticalOffset); } } diff --git a/app/src/main/java/se/ajpanton/statusbartweak/shell/ui/LayoutFragment.java b/app/src/main/java/se/ajpanton/statusbartweak/shell/ui/LayoutFragment.java index 2a75901..1bb480f 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/shell/ui/LayoutFragment.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/shell/ui/LayoutFragment.java @@ -178,6 +178,14 @@ public final class LayoutFragment extends Fragment { bindCheckBoxPref(prefs, root.findViewById(R.id.clock_position_mode_unlocked), SbtSettings.KEY_CLOCK_ENABLED_UNLOCKED, SbtDefaults.CLOCK_ENABLED_UNLOCKED_DEFAULT); + LayoutSectionCopySupport.addDropdown( + requireContext(), + prefs, + root.findViewById(R.id.clock_position_mode_unlocked), + LayoutSectionCopySupport.Mode.UNLOCKED, + LayoutSectionCopySupport.Section.CLOCK, + false, + this::refreshSelf); bindCheckBoxPref(prefs, root.findViewById(R.id.notif_position_mode_aod), SbtSettings.KEY_LAYOUT_NOTIF_ENABLED_AOD, SbtDefaults.LAYOUT_NOTIF_ENABLED_AOD_DEFAULT); @@ -221,7 +229,8 @@ public final class LayoutFragment extends Fragment { SbtDefaults.LAYOUT_NOTIF_POSITION_DEFAULT, SbtDefaults.LAYOUT_NOTIF_MIDDLE_AUTO_NEAREST_SIDE_DEFAULT, SbtDefaults.LAYOUT_NOTIF_MIDDLE_SIDE_DEFAULT, - SbtDefaults.LAYOUT_NOTIF_VERTICAL_OFFSET_PX_DEFAULT); + SbtDefaults.LAYOUT_NOTIF_VERTICAL_OFFSET_PX_DEFAULT, + LayoutSectionCopySupport.Section.NOTIFICATIONS_ICONS); setupUnlockedIconContainerCount( root, prefs, @@ -238,7 +247,8 @@ public final class LayoutFragment extends Fragment { SbtDefaults.LAYOUT_STATUS_POSITION_DEFAULT, SbtDefaults.LAYOUT_STATUS_MIDDLE_AUTO_NEAREST_SIDE_DEFAULT, SbtDefaults.LAYOUT_STATUS_MIDDLE_SIDE_DEFAULT, - SbtDefaults.LAYOUT_STATUS_VERTICAL_OFFSET_PX_DEFAULT); + SbtDefaults.LAYOUT_STATUS_VERTICAL_OFFSET_PX_DEFAULT, + LayoutSectionCopySupport.Section.STATUS); return root; } @@ -262,7 +272,8 @@ public final class LayoutFragment extends Fragment { String positionDefault, boolean autoNearestDefault, String middleSideDefault, - int verticalOffsetDefault + int verticalOffsetDefault, + LayoutSectionCopySupport.Section copySection ) { CheckBox checkbox = root.findViewById(checkboxId); if (checkbox == null || !(checkbox.getParent() instanceof ViewGroup modeRow) @@ -287,6 +298,17 @@ public final class LayoutFragment extends Fragment { removeGeneratedCopyCards(cardsParent, copyTag); int count = iconContainerCount(prefs, countKey, countDefault, legacyEnabledKey, legacyEnabledDefault); updateIconCardTitle(sourceCard, sectionTitleId, 0, count); + boolean cardsMode = LockedNotificationModeUi.MODE_CARDS.equals( + LockedNotificationModeUi.currentMode(requireContext())); + LayoutSectionCopySupport.addDropdownRow( + requireContext(), + prefs, + countContainer, + LayoutSectionCopySupport.Mode.UNLOCKED, + copySection, + cardsMode, + 0, + this::refreshSelf); countContainer.addView(iconContainerCountControl( prefs, countKey, @@ -322,6 +344,22 @@ public final class LayoutFragment extends Fragment { rebuild[0].run(); } + private void refreshSelf() { + View view = getView(); + if (view == null || !isAdded()) { + return; + } + view.post(() -> { + if (isAdded()) { + getParentFragmentManager() + .beginTransaction() + .detach(this) + .attach(this) + .commitAllowingStateLoss(); + } + }); + } + private View iconContainerCountControl( SharedPreferences prefs, String countKey, 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 new file mode 100644 index 0000000..90e43a8 --- /dev/null +++ b/app/src/main/java/se/ajpanton/statusbartweak/shell/ui/LayoutSectionCopySupport.java @@ -0,0 +1,669 @@ +package se.ajpanton.statusbartweak.shell.ui; + +import android.content.Context; +import android.content.SharedPreferences; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.CheckBox; +import android.widget.LinearLayout; +import android.widget.Spinner; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; + +import se.ajpanton.statusbartweak.R; +import se.ajpanton.statusbartweak.shell.settings.SbtDefaults; +import se.ajpanton.statusbartweak.shell.settings.SbtSettings; + +final class LayoutSectionCopySupport { + private static final String COPY_ROW_TAG = "layout_section_copy_row"; + + enum Mode { + UNLOCKED, + LOCKSCREEN, + AOD + } + + enum Section { + CLOCK, + STATUS, + NOTIFICATIONS_ICONS, + NOTIFICATIONS_CARDS + } + + private static final int MAX_COPIES = SbtDefaults.LAYOUT_ICON_CONTAINER_COUNT_MAX; + + private LayoutSectionCopySupport() { + } + + static void addDropdown( + Context context, + SharedPreferences prefs, + CheckBox enabled, + Mode destinationMode, + Section section, + boolean cardsMode, + @Nullable Runnable refresh + ) { + if (context == null || prefs == null || enabled == null) { + return; + } + ArrayList