Initial clean project state

This commit is contained in:
ajp_anton
2026-05-04 11:59:40 +00:00
commit 3f187e911b
141 changed files with 27112 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+144
View File
@@ -0,0 +1,144 @@
import java.io.File
import java.util.Properties
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
plugins {
id("com.android.application")
}
data class SharedReleaseSigning(
val storeFile: File,
val keyAlias: String,
val storePassword: String,
val keyPassword: String,
)
val fallbackVersionCode = Instant.now().epochSecond
.coerceAtMost(Int.MAX_VALUE.toLong())
.toInt()
val fallbackVersionName = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
.withZone(ZoneOffset.UTC)
.format(Instant.ofEpochSecond(fallbackVersionCode.toLong()))
fun loadSharedReleaseSigning(project: org.gradle.api.Project): SharedReleaseSigning? {
val userHome = System.getProperty("user.home") ?: return null
val configFile = File(userHome, ".config/android/release-signing.properties")
if (!configFile.isFile) {
return null
}
val props = Properties().apply {
configFile.inputStream().use(::load)
}
val rawStoreFile = props.getProperty("storeFile")?.trim().orEmpty()
val keyAlias = props.getProperty("keyAlias")?.trim().orEmpty()
val storePassword = project.providers.gradleProperty("androidReleaseStorePassword").orNull?.trim().orEmpty()
val keyPassword = project.providers.gradleProperty("androidReleaseKeyPassword").orNull?.trim().orEmpty()
if (rawStoreFile.isEmpty() || keyAlias.isEmpty() || storePassword.isEmpty() || keyPassword.isEmpty()) {
return null
}
val storeFile = File(rawStoreFile).let { candidate ->
if (candidate.isAbsolute) candidate else File(configFile.parentFile, rawStoreFile)
}
if (!storeFile.isFile) {
return null
}
return SharedReleaseSigning(
storeFile = storeFile,
keyAlias = keyAlias,
storePassword = storePassword,
keyPassword = keyPassword,
)
}
val sharedReleaseSigning = loadSharedReleaseSigning(project)
val releaseTaskRequested = gradle.startParameter.taskNames.any { taskName ->
taskName.contains("Release", ignoreCase = true)
|| taskName.contains("Bundle", ignoreCase = true)
}
android {
namespace = "se.ajpanton.statusbartweak"
compileSdk = 36
defaultConfig {
applicationId = "se.ajpanton.statusbartweak"
minSdk = 35
targetSdk = 36
val buildVersionName = project.findProperty("sbtVersionName")?.toString()
?: fallbackVersionName
val buildVersionCode = project.findProperty("sbtVersionCode")?.toString()?.toIntOrNull()
?: fallbackVersionCode
versionCode = buildVersionCode
versionName = buildVersionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
if (sharedReleaseSigning != null) {
create("release") {
storeFile = sharedReleaseSigning.storeFile
storePassword = sharedReleaseSigning.storePassword
keyAlias = sharedReleaseSigning.keyAlias
keyPassword = sharedReleaseSigning.keyPassword
}
}
}
buildTypes {
getByName("debug") {
// Intentionally unchanged: local debug installs keep using the default debug signing.
}
getByName("release") {
if (sharedReleaseSigning != null) {
signingConfig = signingConfigs.getByName("release")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
// Modern replacement for packagingOptions
packaging {
resources {
excludes += setOf(
"META-INF/LICENSE",
"META-INF/NOTICE"
)
}
}
}
if (releaseTaskRequested && sharedReleaseSigning == null) {
throw GradleException(
buildString {
appendLine("Release signing is not configured.")
appendLine("Expected:")
appendLine("- ~/.config/android/release-signing.properties with storeFile and keyAlias")
appendLine("- ~/.gradle/gradle.properties with androidReleaseStorePassword and androidReleaseKeyPassword")
}
)
}
dependencies {
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("com.google.android.material:material:1.9.0")
// Modern Xposed API, provided by the official libxposed Maven artifact
compileOnly("io.github.libxposed:api:101.0.1")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package se.ajpanton.statusbartweak;
import android.content.Context;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("se.ajpanton.statusbartweak", appContext.getPackageName());
}
}
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:label="@string/app_name"
android:description="@string/module_description"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".shell.ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.MoreNotis.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".shell.debug.DebugBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
android:name=".shell.debug.DebugNotificationDeleteReceiver"
android:exported="false" />
<provider
android:name=".shell.settings.SbtSettingsProvider"
android:authorities="se.ajpanton.statusbartweak.settings"
android:exported="true" />
</application>
</manifest>
@@ -0,0 +1,62 @@
package se.ajpanton.statusbartweak.module;
import io.github.libxposed.api.XposedInterface;
import io.github.libxposed.api.XposedModuleInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.features.StandaloneFeatureRegistry;
import se.ajpanton.statusbartweak.runtime.mode.ModeStateRepository;
/**
* Fresh rebuild entrypoint.
*
* Intentionally does not install layout/icon hooks yet. During the reset
* phase, the settings app remains available while runtime behaviour is rebuilt
* slice by slice under the runtime package tree.
*/
public final class ModuleCoordinator {
private static final String PACKAGE_ANDROID = "android";
private static final String PACKAGE_SYSTEM = "system";
private static final String PACKAGE_SYSTEMUI = "com.android.systemui";
private static final String PACKAGE_AODSERVICE = "com.samsung.android.app.aodservice";
private final RuntimeContext runtimeContext;
private final StandaloneFeatureRegistry standaloneFeatureRegistry;
public ModuleCoordinator(XposedInterface framework) {
ModeStateRepository modeStateRepository = new ModeStateRepository();
runtimeContext = new RuntimeContext(
framework,
modeStateRepository);
standaloneFeatureRegistry = new StandaloneFeatureRegistry(runtimeContext);
}
public void onModuleLoaded(XposedModuleInterface.ModuleLoadedParam param) {
String processName = param.getProcessName();
if (!PACKAGE_SYSTEMUI.equals(processName)
&& !PACKAGE_ANDROID.equals(processName)
&& !PACKAGE_SYSTEM.equals(processName)
&& !PACKAGE_AODSERVICE.equals(processName)) {
return;
}
standaloneFeatureRegistry.onModuleLoaded(param);
}
public void onPackageReady(XposedModuleInterface.PackageReadyParam param) {
if (!shouldHandlePackage(param.getPackageName())) {
return;
}
standaloneFeatureRegistry.onPackageReady(param);
}
public void onSystemServerStarting(XposedModuleInterface.SystemServerStartingParam param) {
standaloneFeatureRegistry.onSystemServerStarting(param);
}
private static boolean shouldHandlePackage(String packageName) {
return PACKAGE_SYSTEMUI.equals(packageName)
|| PACKAGE_ANDROID.equals(packageName)
|| PACKAGE_SYSTEM.equals(packageName)
|| PACKAGE_AODSERVICE.equals(packageName);
}
}
@@ -0,0 +1,80 @@
package se.ajpanton.statusbartweak.module;
import java.io.File;
import io.github.libxposed.api.XposedInterface;
import io.github.libxposed.api.XposedModule;
import io.github.libxposed.api.XposedModuleInterface;
/**
* Modern module entrypoint.
*/
public final class StatusBarTweakModule extends XposedModule {
private static final String FAILSAFE_PATH = "/data/local/tmp/disable_statusbar_fix";
private final ModuleCoordinator moduleCoordinator = new ModuleCoordinator(this);
private boolean moduleLoadedHandled;
public StatusBarTweakModule() {
}
/**
* Compatibility constructor for frameworks that still instantiate modern
* modules with the older constructor-based contract.
*/
@SuppressWarnings("unused")
public StatusBarTweakModule(
XposedInterface framework,
XposedModuleInterface.ModuleLoadedParam param
) {
attachFrameworkIfAvailable(framework);
handleModuleLoaded(param);
}
@Override
public void onModuleLoaded(ModuleLoadedParam param) {
handleModuleLoaded(param);
}
private void handleModuleLoaded(ModuleLoadedParam param) {
if (moduleLoadedHandled) {
return;
}
moduleLoadedHandled = true;
if (new File(FAILSAFE_PATH).exists()) {
return;
}
moduleCoordinator.onModuleLoaded(param);
}
private void attachFrameworkIfAvailable(XposedInterface framework) {
if (framework == null) {
return;
}
try {
getClass()
.getMethod("attachFramework", XposedInterface.class)
.invoke(this, framework);
} catch (Throwable ignored) {
// Older modern-API LSPosed builds may still use the constructor-based
// contract without exposing attachFramework() on the runtime API.
}
}
@Override
public void onPackageReady(PackageReadyParam param) {
if (new File(FAILSAFE_PATH).exists()) {
return;
}
moduleCoordinator.onPackageReady(param);
}
@Override
public void onSystemServerStarting(XposedModuleInterface.SystemServerStartingParam param) {
if (new File(FAILSAFE_PATH).exists()) {
return;
}
moduleCoordinator.onSystemServerStarting(param);
}
}
@@ -0,0 +1,122 @@
package se.ajpanton.statusbartweak.runtime.features;
import android.util.Log;
import java.util.Objects;
import io.github.libxposed.api.XposedInterface;
import se.ajpanton.statusbartweak.runtime.mode.ModeStateRepository;
/**
* Shared runtime services for independently rebuilt features.
*
* Standalone slices such as Battery Bar, Misc, and Status Chips should depend
* on this narrow context instead of reaching back into one large controller.
*/
public final class RuntimeContext {
private static final String LOG_TAG = "StatusBarTweak";
private static final int MAX_LOG_MESSAGE_CHARS = 1800;
private final XposedInterface framework;
private final ModeStateRepository modeStateRepository;
public RuntimeContext(
XposedInterface framework,
ModeStateRepository modeStateRepository
) {
this.framework = Objects.requireNonNull(framework, "framework");
this.modeStateRepository = Objects.requireNonNull(modeStateRepository, "modeStateRepository");
}
public XposedInterface getFramework() {
return framework;
}
public ModeStateRepository getModeStateRepository() {
return modeStateRepository;
}
public boolean isLogEnabled() {
return RuntimeLogGate.isEnabled();
}
public void logInfo(String message) {
logSplit(Log.INFO, message, null);
}
public void logWarning(String message, Throwable throwable) {
logSplit(Log.WARN, message, throwable);
}
public void logError(String message, Throwable throwable) {
logSplit(Log.ERROR, message, throwable);
}
private void logSplit(int priority, String message, Throwable throwable) {
if (!RuntimeLogGate.isEnabled()) {
return;
}
String safeMessage = message != null ? message : "";
if (safeMessage.length() <= MAX_LOG_MESSAGE_CHARS) {
if (throwable != null) {
framework.log(priority, LOG_TAG, safeMessage, throwable);
} else {
framework.log(priority, LOG_TAG, safeMessage);
}
return;
}
PrefixParts prefixParts = splitPrefix(safeMessage);
int bodyChunkSize = Math.max(
1,
MAX_LOG_MESSAGE_CHARS - prefixParts.prefix.length() - 20);
int partCount = (prefixParts.body.length() + bodyChunkSize - 1) / bodyChunkSize;
int digits = String.valueOf(partCount).length();
for (int i = 0; i < partCount; i++) {
int start = i * bodyChunkSize;
int end = Math.min(prefixParts.body.length(), start + bodyChunkSize);
String part = prefixParts.prefix
+ " [part "
+ leftPad(i + 1, digits)
+ "/"
+ leftPad(partCount, digits)
+ "]"
+ prefixParts.separator
+ prefixParts.body.substring(start, end);
if (throwable != null && i == 0) {
framework.log(priority, LOG_TAG, part, throwable);
} else {
framework.log(priority, LOG_TAG, part);
}
}
}
private PrefixParts splitPrefix(String message) {
int colon = message.indexOf(':');
if (colon <= 0 || colon > 80) {
return new PrefixParts("", "", message);
}
String prefix = message.substring(0, colon);
String body = message.substring(colon + 1);
String separator = ":";
if (body.startsWith(" ")) {
separator = ": ";
body = body.substring(1);
}
return new PrefixParts(prefix, separator, body);
}
private String leftPad(int value, int width) {
String text = String.valueOf(value);
StringBuilder out = new StringBuilder(width);
for (int i = text.length(); i < width; i++) {
out.append('0');
}
out.append(text);
return out.toString();
}
private record PrefixParts(String prefix, String separator, String body) {
}
}
@@ -0,0 +1,33 @@
package se.ajpanton.statusbartweak.runtime.features;
import io.github.libxposed.api.XposedModuleInterface;
/**
* Small independent runtime slice.
*
* The rebuild is intentionally moving away from one giant Xposed init class.
* Each feature gets its own lifecycle and can stay outside the icon-layout
* engine until it truly needs to integrate with it.
*/
public interface RuntimeFeature {
String getName();
default void onModuleLoaded(
RuntimeContext context,
XposedModuleInterface.ModuleLoadedParam param
) throws Throwable {
}
default void onPackageReady(
RuntimeContext context,
XposedModuleInterface.PackageReadyParam param
) throws Throwable {
}
default void onSystemServerStarting(
RuntimeContext context,
XposedModuleInterface.SystemServerStartingParam param
) throws Throwable {
}
}
@@ -0,0 +1,88 @@
package se.ajpanton.statusbartweak.runtime.features;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport;
import se.ajpanton.statusbartweak.shell.settings.SbtDefaults;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
final class RuntimeLogGate {
private static final Object LOCK = new Object();
private static Boolean cachedEnabled;
private static boolean receiverRegistered;
private RuntimeLogGate() {
}
static boolean isEnabled() {
Boolean enabled = cachedEnabled;
if (enabled != null) {
return enabled;
}
Context context = getSystemContext();
if (context == null) {
return SbtDefaults.DEBUG_WRITE_LOGS_DEFAULT;
}
registerReceiverIfNeeded(context);
synchronized (LOCK) {
if (cachedEnabled == null) {
cachedEnabled = SbtSettings.isDebugLogWritingEnabled(context);
}
return cachedEnabled != null ? cachedEnabled : SbtDefaults.DEBUG_WRITE_LOGS_DEFAULT;
}
}
private static void invalidate() {
synchronized (LOCK) {
cachedEnabled = null;
}
}
private static void registerReceiverIfNeeded(Context context) {
if (receiverRegistered || context == null) {
return;
}
synchronized (LOCK) {
if (receiverRegistered) {
return;
}
Context appContext = context.getApplicationContext();
if (appContext == null) {
appContext = context;
}
BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
invalidate();
}
};
IntentFilter filter = new IntentFilter(SbtSettings.ACTION_SETTINGS_CHANGED);
if (Build.VERSION.SDK_INT >= 33) {
appContext.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED);
} else {
appContext.registerReceiver(receiver, filter);
}
receiverRegistered = true;
}
}
private static Context getSystemContext() {
try {
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Object app = ReflectionSupport.invokeStaticMethod(
activityThreadClass,
"currentApplication",
new Class<?>[0]
);
return app instanceof Context context ? context : null;
} catch (Throwable ignored) {
return null;
}
}
}
@@ -0,0 +1,69 @@
package se.ajpanton.statusbartweak.runtime.features;
import java.util.List;
import io.github.libxposed.api.XposedModuleInterface;
import se.ajpanton.statusbartweak.runtime.features.battery.BatteryBarFeature;
import se.ajpanton.statusbartweak.runtime.features.chips.StatusChipsFeature;
import se.ajpanton.statusbartweak.runtime.features.clock.ClockFeature;
import se.ajpanton.statusbartweak.runtime.features.debug.DebugNotificationAutoGroupFeature;
import se.ajpanton.statusbartweak.runtime.features.misc.MiscFeature;
import se.ajpanton.statusbartweak.runtime.features.notifications.StockUnlockedNotificationIconsFeature;
import se.ajpanton.statusbartweak.runtime.layout.StockLayoutCanvasFeature;
import se.ajpanton.statusbartweak.runtime.mode.SystemUiStatusBarStateFeature;
/**
* Tracks independently rebuilt features that do not need the custom icon
* layout engine yet.
*/
public final class StandaloneFeatureRegistry {
private final RuntimeContext runtimeContext;
private final List<RuntimeFeature> features;
public StandaloneFeatureRegistry(RuntimeContext runtimeContext) {
this.runtimeContext = runtimeContext;
this.features = List.of(
new SystemUiStatusBarStateFeature(),
new StockLayoutCanvasFeature(),
new ClockFeature(),
new StockUnlockedNotificationIconsFeature(),
new BatteryBarFeature(),
new MiscFeature(),
new StatusChipsFeature(),
new DebugNotificationAutoGroupFeature()
);
}
public void onModuleLoaded(XposedModuleInterface.ModuleLoadedParam param) {
forEachFeature(feature -> feature.onModuleLoaded(runtimeContext, param));
}
public void onPackageReady(XposedModuleInterface.PackageReadyParam param) {
forEachFeature(feature -> feature.onPackageReady(runtimeContext, param));
}
public void onSystemServerStarting(XposedModuleInterface.SystemServerStartingParam param) {
forEachFeature(feature -> feature.onSystemServerStarting(runtimeContext, param));
}
private void forEachFeature(FeatureAction action) {
for (RuntimeFeature feature : features) {
try {
action.run(feature);
} catch (Throwable throwable) {
runtimeContext.logError(
"Feature bootstrap failed: " + feature.getName()
+ " throwable=" + throwable.getClass().getName()
+ ": " + throwable.getMessage(),
throwable
);
}
}
}
@FunctionalInterface
private interface FeatureAction {
void run(RuntimeFeature feature) throws Throwable;
}
}
@@ -0,0 +1,487 @@
package se.ajpanton.statusbartweak.runtime.features.battery;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.os.BatteryManager;
import android.os.Build;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.FrameLayout;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import io.github.libxposed.api.XposedInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport;
import se.ajpanton.statusbartweak.runtime.hooks.XposedHookSupport;
import se.ajpanton.statusbartweak.runtime.settings.RuntimeSettingsCache;
import se.ajpanton.statusbartweak.shell.settings.BatteryBarStyle;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
final class BatteryBarController {
private static final String[] ROOT_CLASSES = new String[] {
"com.android.systemui.statusbar.phone.PhoneStatusBarView",
"com.android.systemui.statusbar.phone.KeyguardStatusBarView"
};
private final RuntimeContext runtimeContext;
private final Set<View> roots = Collections.newSetFromMap(new WeakHashMap<>());
private final Map<View, BatteryBarView> batteryBarViews = new WeakHashMap<>();
private final Map<View, View.OnLayoutChangeListener> rootLayoutListeners = new WeakHashMap<>();
private final Map<View, String> lastSignatures = new WeakHashMap<>();
private boolean hooksInstalled;
private boolean receiverRegistered;
private int batteryLevel = -1;
private boolean plugged;
BatteryBarController(RuntimeContext runtimeContext) {
this.runtimeContext = runtimeContext;
}
public void install(ClassLoader classLoader) {
if (hooksInstalled || classLoader == null) {
return;
}
for (String className : ROOT_CLASSES) {
installHooksForClass(classLoader, className);
}
hooksInstalled = true;
}
private void installHooksForClass(ClassLoader classLoader, String className) {
Class<?> rootClass = ReflectionSupport.findClassIfExists(className, classLoader);
if (rootClass == null) {
return;
}
XposedInterface framework = runtimeContext.getFramework();
XposedHookSupport.hookAllConstructors(framework, rootClass, chain -> {
Object result = chain.proceed();
Object target = chain.getThisObject();
if (target instanceof View root) {
trackAndApplyRoot(root);
}
return result;
});
XposedHookSupport.hookAllMethodsIfExists(framework, rootClass, "onFinishInflate", chain -> {
Object result = chain.proceed();
Object target = chain.getThisObject();
if (target instanceof View root) {
trackAndApplyRoot(root);
}
return result;
});
XposedHookSupport.hookAllMethodsIfExists(framework, rootClass, "onAttachedToWindow", chain -> {
Object result = chain.proceed();
Object target = chain.getThisObject();
if (target instanceof View root) {
trackAndApplyRoot(root);
}
return result;
});
XposedHookSupport.hookAllMethodsIfExists(framework, rootClass, "onDetachedFromWindow", chain -> {
Object result = chain.proceed();
Object target = chain.getThisObject();
if (target instanceof View root) {
untrackRoot(root);
}
return result;
});
}
private void trackAndApplyRoot(View root) {
roots.add(root);
registerReceiverIfNeeded(root.getContext());
ensureRootLayoutListener(root);
applyBatteryBar(root);
}
private void untrackRoot(View root) {
View.OnLayoutChangeListener listener = rootLayoutListeners.remove(root);
if (listener != null) {
root.removeOnLayoutChangeListener(listener);
}
roots.remove(root);
lastSignatures.remove(root);
removeBatteryBarView(root);
}
private void registerReceiverIfNeeded(Context context) {
if (context == null || receiverRegistered) {
return;
}
Context appContext = context.getApplicationContext();
if (appContext == null) {
appContext = context;
}
BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context ctx, Intent intent) {
String action = intent.getAction();
if (Intent.ACTION_BATTERY_CHANGED.equals(action)) {
updateBatteryState(intent);
}
if (Intent.ACTION_BATTERY_CHANGED.equals(action)
|| SbtSettings.ACTION_SETTINGS_CHANGED.equals(action)) {
refreshAllRoots();
}
}
};
IntentFilter filter = new IntentFilter();
filter.addAction(Intent.ACTION_BATTERY_CHANGED);
filter.addAction(Intent.ACTION_USER_UNLOCKED);
filter.addAction(SbtSettings.ACTION_SETTINGS_CHANGED);
Intent sticky;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
sticky = appContext.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED);
} else {
sticky = appContext.registerReceiver(receiver, filter);
}
receiverRegistered = true;
if (sticky != null) {
updateBatteryState(sticky);
}
}
private void updateBatteryState(Intent intent) {
int level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
int scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100);
if (level >= 0 && scale > 0) {
batteryLevel = Math.round((level * 100f) / scale);
}
int status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
|| status == BatteryManager.BATTERY_STATUS_CHARGING
|| status == BatteryManager.BATTERY_STATUS_FULL;
}
private void refreshAllRoots() {
for (View root : new ArrayList<>(roots)) {
applyBatteryBar(root);
}
}
private void ensureRootLayoutListener(View root) {
if (rootLayoutListeners.containsKey(root)) {
return;
}
View.OnLayoutChangeListener listener = new View.OnLayoutChangeListener() {
@Override
public void onLayoutChange(
View v,
int left,
int top,
int right,
int bottom,
int oldLeft,
int oldTop,
int oldRight,
int oldBottom
) {
int width = right - left;
int height = bottom - top;
int oldWidth = oldRight - oldLeft;
int oldHeight = oldBottom - oldTop;
if (width == oldWidth && height == oldHeight) {
return;
}
if (!roots.contains(root) || !root.isAttachedToWindow()) {
return;
}
applyBatteryBar(root);
}
};
root.addOnLayoutChangeListener(listener);
rootLayoutListeners.put(root, listener);
}
private void applyBatteryBar(View root) {
if (!(root instanceof ViewGroup rootGroup)) {
removeBatteryBarView(root);
return;
}
SbtSettings settings = RuntimeSettingsCache.get(root.getContext());
if (!settings.batteryBarEnabled || root.getWidth() <= 0 || root.getHeight() <= 0) {
removeBatteryBarView(root);
return;
}
int color = resolveBarColor(root, settings);
String signature = buildSignature(root, settings, color);
BatteryBarView existingView = batteryBarViews.get(root);
String lastSignature = lastSignatures.get(root);
if (signature.equals(lastSignature) && existingView != null && existingView.getParent() == rootGroup) {
existingView.bringToFront();
return;
}
BatteryBarView barView = ensureBatteryBarView(rootGroup);
layoutBatteryBarView(rootGroup, barView);
barView.update(rootGroup, settings, batteryLevel, plugged, color);
barView.bringToFront();
barView.invalidate();
lastSignatures.put(root, signature);
}
private BatteryBarView ensureBatteryBarView(ViewGroup root) {
BatteryBarView view = batteryBarViews.get(root);
if (view == null) {
view = new BatteryBarView(root.getContext());
view.setClickable(false);
view.setFocusable(false);
view.setFocusableInTouchMode(false);
batteryBarViews.put(root, view);
}
ensureAttached(root, view);
return view;
}
private void ensureAttached(ViewGroup root, BatteryBarView view) {
if (view.getParent() == root) {
ViewGroup.LayoutParams params = view.getLayoutParams();
if (params == null
|| params.width != ViewGroup.LayoutParams.MATCH_PARENT
|| params.height != ViewGroup.LayoutParams.MATCH_PARENT) {
view.setLayoutParams(createLayoutParams(root));
}
return;
}
removeFromParent(view);
root.addView(view, createLayoutParams(root));
layoutBatteryBarView(root, view);
}
private void layoutBatteryBarView(ViewGroup root, BatteryBarView view) {
int width = root.getWidth();
int height = root.getHeight();
if (width <= 0 || height <= 0) {
return;
}
int widthSpec = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY);
view.measure(widthSpec, heightSpec);
view.layout(0, 0, width, height);
}
private ViewGroup.LayoutParams createLayoutParams(ViewGroup parent) {
if (parent instanceof FrameLayout) {
return new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
);
}
return new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
);
}
private String buildSignature(View root, SbtSettings settings, int color) {
return root.getWidth() + "|"
+ root.getHeight() + "|"
+ batteryLevel + "|"
+ plugged + "|"
+ color + "|"
+ settings.batteryBarEnabled + "|"
+ settings.batteryBarPosition + "|"
+ settings.batteryBarAlignment + "|"
+ settings.batteryBarThicknessDp + "|"
+ settings.batteryBarEdgeOffsetDp + "|"
+ settings.batteryBarMinLevel + "|"
+ settings.batteryBarMaxLevel + "|"
+ settings.batteryBarDefaultDischargeColor + "|"
+ settings.batteryBarDefaultChargeColor + "|"
+ BatteryBarStyle.encodeThresholds(settings.batteryBarThresholds);
}
private void removeBatteryBarView(View root) {
BatteryBarView view = batteryBarViews.remove(root);
lastSignatures.remove(root);
if (view != null) {
removeFromParent(view);
}
}
private int resolveBarColor(View root, SbtSettings settings) {
int fallback = resolveClockColor(root);
return BatteryBarStyle.resolveColor(
batteryLevel,
plugged,
settings.batteryBarDefaultDischargeColor,
settings.batteryBarDefaultChargeColor,
settings.batteryBarThresholds,
fallback,
fallback
);
}
private int resolveClockColor(View root) {
View stockClock = findClock(root);
if (stockClock instanceof TextView textView) {
return textView.getCurrentTextColor();
}
return Color.WHITE;
}
private static View findClock(View root) {
if (matchesClockId(root)) {
return root;
}
if (!(root instanceof ViewGroup group)) {
return null;
}
for (int index = 0; index < group.getChildCount(); index++) {
View child = group.getChildAt(index);
View found = findClock(child);
if (found != null) {
return found;
}
}
return null;
}
private static boolean matchesClockId(View view) {
int id = view.getId();
if (id == View.NO_ID) {
return false;
}
try {
return "clock".equals(view.getResources().getResourceEntryName(id));
} catch (Resources.NotFoundException ignored) {
return false;
}
}
private static int dpToPx(Context context, int dp) {
float density = context.getResources().getDisplayMetrics().density;
return Math.round(dp * density);
}
private static void removeFromParent(View view) {
ViewParent parent = view.getParent();
if (parent instanceof ViewGroup group) {
group.removeView(view);
}
}
private static final class BatteryBarView extends View {
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Rect drawRect = new Rect();
private ViewGroup host;
private SbtSettings settings;
private int batteryLevel = -1;
private boolean charging;
BatteryBarView(Context context) {
super(context);
paint.setStyle(Paint.Style.FILL);
setWillNotDraw(false);
}
void update(
ViewGroup host,
SbtSettings settings,
int batteryLevel,
boolean charging,
int color
) {
this.host = host;
this.settings = settings;
this.batteryLevel = batteryLevel;
this.charging = charging;
paint.setColor(color);
paint.setAlpha(charging ? 255 : 230);
buildDrawRect();
}
private void buildDrawRect() {
drawRect.setEmpty();
if (host == null || settings == null) {
return;
}
int width = getWidth() > 0 ? getWidth() : host.getWidth();
int height = getHeight() > 0 ? getHeight() : host.getHeight();
if (width <= 0 || height <= 0) {
return;
}
int thickness = Math.max(1, dpToPx(getContext(), settings.batteryBarThicknessDp));
int edgeOffset = Math.max(0, dpToPx(getContext(), settings.batteryBarEdgeOffsetDp));
int left = edgeOffset;
int right = width - edgeOffset;
if (right <= left) {
return;
}
int top = resolveTop(settings, thickness, height);
int bottom = Math.min(height, top + thickness);
if (bottom <= top) {
return;
}
float fraction = resolveFraction(batteryLevel, settings);
int availableWidth = right - left;
int barWidth = Math.round(availableWidth * fraction);
if (barWidth <= 0) {
return;
}
if ("rtl".equals(settings.batteryBarAlignment)) {
drawRect.set(right - barWidth, top, right, bottom);
} else if ("center".equals(settings.batteryBarAlignment)) {
int centerX = (left + right) / 2;
int half = barWidth / 2;
drawRect.set(centerX - half, top, centerX - half + barWidth, bottom);
} else {
drawRect.set(left, top, left + barWidth, bottom);
}
}
private int resolveTop(SbtSettings settings, int thickness, int height) {
if ("bottom".equals(settings.batteryBarPosition)) {
return Math.max(0, height - thickness);
}
return 0;
}
private float resolveFraction(int batteryLevel, SbtSettings settings) {
if (batteryLevel < 0) {
return 0f;
}
int min = settings.batteryBarMinLevel;
int max = settings.batteryBarMaxLevel;
if (max <= min) {
return batteryLevel >= max ? 1f : 0f;
}
if (batteryLevel <= min) {
return 0f;
}
if (batteryLevel >= max) {
return 1f;
}
return (batteryLevel - min) / (float) (max - min);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
buildDrawRect();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (!drawRect.isEmpty()) {
canvas.drawRect(drawRect, paint);
}
}
}
}
@@ -0,0 +1,40 @@
package se.ajpanton.statusbartweak.runtime.features.battery;
import io.github.libxposed.api.XposedModuleInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.features.RuntimeFeature;
/**
* Reserved home for the rebuilt Battery Bar runtime.
*
* This feature is intentionally separate from the future icon layout engine.
* The old implementation mostly owned its own overlays and can be ported as a
* standalone slice first.
*/
public final class BatteryBarFeature implements RuntimeFeature {
private BatteryBarController controller;
@Override
public String getName() {
return "battery-bar";
}
@Override
public void onPackageReady(
RuntimeContext context,
XposedModuleInterface.PackageReadyParam param
) {
if (param == null || !"com.android.systemui".equals(param.getPackageName())) {
return;
}
installController(context, param.getClassLoader());
}
private void installController(RuntimeContext context, ClassLoader classLoader) {
if (controller == null) {
controller = new BatteryBarController(context);
}
controller.install(classLoader);
}
}
@@ -0,0 +1,917 @@
package se.ajpanton.statusbartweak.runtime.features.chips;
import android.app.KeyguardManager;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.TextView;
import java.util.ArrayDeque;
import java.util.Collections;
import java.util.Set;
import java.util.WeakHashMap;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.hooks.InstanceStateStore;
import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport;
import se.ajpanton.statusbartweak.runtime.hooks.XposedHookSupport;
import se.ajpanton.statusbartweak.runtime.settings.RuntimeSettingsCache;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
final class StatusChipHider {
private static final String FIELD_FORCED_HIDDEN = "sbtStatusChipForcedHidden";
private static final String FIELD_PREV_VIS = "sbtStatusChipPrevVis";
private static final int TOP_REGION_DP = 220;
private static final int MAX_TEXT_SCAN_LEN = 160;
private static final int MAX_CLASS_SCAN_LEN = 480;
private static final int MAX_TEXT_NODES = 48;
private static final int MAX_ASCENT = 8;
private static final int ZONE_NONE = 0;
private static final int ZONE_TOP = 1;
private static final int KIND_UNKNOWN = 0;
private static final int KIND_MEDIA = 1;
private static final int KIND_NAV = 2;
private static final int KIND_CALL = 3;
private final RuntimeContext runtimeContext;
private final InstanceStateStore instanceStateStore = new InstanceStateStore();
private final Set<View> trackedRoots = Collections.newSetFromMap(new WeakHashMap<>());
private final Set<View> scannedRoots = Collections.newSetFromMap(new WeakHashMap<>());
private final Set<View> forcedHiddenViews = Collections.newSetFromMap(new WeakHashMap<>());
private final Set<Object> ongoingControllers = Collections.newSetFromMap(new WeakHashMap<>());
private boolean hooksInstalled;
private boolean broadcastInstalled;
private KeyguardManager keyguardManager;
private Class<?> ongoingActivityDataHelperClass;
private Class<?> notificationLockscreenUserManagerClass;
private boolean hideAll;
private boolean hideMediaUnlocked;
private boolean hideNavUnlocked;
private boolean hideCallUnlocked;
StatusChipHider(RuntimeContext runtimeContext) {
this.runtimeContext = runtimeContext;
}
void installHooks(ClassLoader classLoader) {
if (hooksInstalled) {
return;
}
installBroadcastReceiver(getSystemContext());
refreshSettings();
installOngoingActivityHooks(classLoader);
installTouchInterceptHooks(classLoader);
installViewRootHooks();
hooksInstalled = true;
}
private void installOngoingActivityHooks(ClassLoader classLoader) {
Class<?> controllerClass = ReflectionSupport.findClassIfExists(
"com.android.systemui.statusbar.phone.ongoingactivity.OngoingActivityController",
classLoader
);
ongoingActivityDataHelperClass = ReflectionSupport.findClassIfExists(
"com.android.systemui.statusbar.phone.ongoingactivity.OngoingActivityDataHelper",
classLoader
);
notificationLockscreenUserManagerClass = ReflectionSupport.findClassIfExists(
"com.android.systemui.statusbar.NotificationLockscreenUserManager",
classLoader
);
if (controllerClass == null
|| ongoingActivityDataHelperClass == null
|| notificationLockscreenUserManagerClass == null) {
return;
}
XposedHookSupport.hookAllConstructors(runtimeContext.getFramework(), controllerClass, chain -> {
Object result = chain.proceed();
Object controller = chain.getThisObject();
if (controller != null) {
ongoingControllers.add(controller);
refreshSettings();
if (isAnyChipHidingEnabled()) {
refreshOngoingActivityController(controller);
rescanTrackedRoots();
}
}
return result;
});
XposedHookSupport.hookAllMethodsIfExists(
runtimeContext.getFramework(),
controllerClass,
"shouldVisible",
chain -> {
Object result = chain.proceed();
refreshSettings();
if (result instanceof Boolean bool && bool && shouldSuppressTopOngoingController()) {
return false;
}
return result;
});
XposedHookSupport.hookAllMethodsIfExists(
runtimeContext.getFramework(),
ongoingActivityDataHelperClass,
"shouldHide",
chain -> {
Object result = chain.proceed();
if (result instanceof Boolean bool && bool) {
return true;
}
if (!shouldHideTopUnlocked() || chain.getArgs().size() < 2) {
return result;
}
Object data = chain.getArg(1);
boolean suppress = shouldHide(classifyOngoingData(data), ZONE_TOP);
return suppress ? true : result;
});
}
private void installViewRootHooks() {
Class<?> viewRootClass = ReflectionSupport.requireClass("android.view.ViewRootImpl", null);
XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), viewRootClass, "performTraversals", chain -> {
Object result = chain.proceed();
Object rootObj = ReflectionSupport.requireFieldValue(chain.getThisObject(), "mView");
if (!(rootObj instanceof View root)) {
return result;
}
installBroadcastReceiver(root.getContext());
trackedRoots.add(root);
if (!isAnyChipHidingEnabled()) {
restoreForcedHiddenViews();
return result;
}
if (scannedRoots.add(root)) {
applyToViewTree(root, false);
} else {
keepForcedHiddenViewsHidden();
}
return result;
});
}
private void installTouchInterceptHooks(ClassLoader classLoader) {
Class<?> targetClass = ReflectionSupport.findClassIfExists(
"com.android.systemui.statusbar.phone.TouchInterceptFrameLayout",
classLoader
);
if (targetClass == null) {
return;
}
XposedHookSupport.hookAllMethodsIfExists(runtimeContext.getFramework(), targetClass, "onLayout", chain -> {
Object result = chain.proceed();
if (!isAnyChipHidingEnabled()) {
return result;
}
if (!(chain.getThisObject() instanceof View view)) {
return result;
}
if (view.getWidth() > 0 && view.getHeight() > 0) {
applySourceDecision(view, false);
}
return result;
});
XposedHookSupport.hookAllMethodsIfExists(runtimeContext.getFramework(), targetClass, "onAttachedToWindow", chain -> {
Object result = chain.proceed();
if (!isAnyChipHidingEnabled()) {
return result;
}
if (chain.getThisObject() instanceof View view && shouldPreemptivelyHide(view)) {
forceHide(view);
}
return result;
});
XposedHookSupport.hookAllMethodsIfExists(runtimeContext.getFramework(), targetClass, "setVisibility", chain -> {
if (!(chain.getThisObject() instanceof View view)) {
return chain.proceed();
}
if (isAnyChipHidingEnabled()
&& !chain.getArgs().isEmpty()
&& chain.getArg(0) instanceof Integer requestedVisibility
&& requestedVisibility != View.GONE
&& shouldPreemptivelyHide(view)) {
markForcedHidden(view, requestedVisibility);
Object[] args = chain.getArgs().toArray();
args[0] = View.GONE;
return chain.proceed(args);
}
Object result = chain.proceed();
if (!isAnyChipHidingEnabled()) {
return result;
}
if (view.getWidth() > 0 && view.getHeight() > 0) {
applySourceDecision(view, false);
}
return result;
});
}
private void installBroadcastReceiver(Context context) {
if (broadcastInstalled) {
return;
}
if (context == null) {
return;
}
Context appContext = context.getApplicationContext();
if (appContext == null) {
appContext = context;
}
IntentFilter filter = new IntentFilter(SbtSettings.ACTION_SETTINGS_CHANGED);
filter.addAction(Intent.ACTION_USER_UNLOCKED);
android.content.BroadcastReceiver receiver = new android.content.BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
RuntimeSettingsCache.invalidate();
refreshSettings();
scannedRoots.clear();
refreshOngoingActivityLists();
rescanTrackedRoots();
}
};
if (Build.VERSION.SDK_INT >= 33) {
appContext.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED);
} else {
appContext.registerReceiver(receiver, filter);
}
broadcastInstalled = true;
}
private void refreshSettings() {
Context context = getSystemContext();
if (context == null) {
hideAll = false;
hideMediaUnlocked = false;
hideNavUnlocked = false;
hideCallUnlocked = false;
return;
}
SbtSettings settings = RuntimeSettingsCache.get(context);
hideAll = settings.statusChipsHideAll;
hideMediaUnlocked = settings.statusChipsHideMediaUnlocked;
hideNavUnlocked = settings.statusChipsHideNavUnlocked;
hideCallUnlocked = settings.statusChipsHideCallUnlocked;
}
private boolean isAnyChipHidingEnabled() {
return hideAll || hideMediaUnlocked || hideNavUnlocked || hideCallUnlocked;
}
private void rescanTrackedRoots() {
if (!isAnyChipHidingEnabled()) {
restoreForcedHiddenViews();
return;
}
for (View root : trackedRoots) {
if (root != null) {
scannedRoots.add(root);
applyToViewTree(root, false);
}
}
}
private void refreshOngoingActivityLists() {
if (ongoingActivityDataHelperClass == null || notificationLockscreenUserManagerClass == null) {
return;
}
for (Object controller : ongoingControllers) {
refreshOngoingActivityController(controller);
}
}
private void refreshOngoingActivityController(Object controller) {
if (controller == null
|| ongoingActivityDataHelperClass == null
|| notificationLockscreenUserManagerClass == null) {
return;
}
Object userManager = ReflectionSupport.getFieldValue(controller, "userManager");
if (userManager == null) {
return;
}
ReflectionSupport.invokeStaticMethod(
ongoingActivityDataHelperClass,
"updateOngoingList",
new Class<?>[]{notificationLockscreenUserManagerClass},
userManager
);
ReflectionSupport.invokeMethod(controller, "updateParentViewVisibility", new Class<?>[]{boolean.class}, false);
ReflectionSupport.invokeMethod(controller, "updateAdapter", new Class<?>[0]);
}
private boolean shouldSuppressTopOngoingController() {
if (!shouldHideTopUnlocked()) {
return false;
}
Object list = ReflectionSupport.getStaticFieldValue(
ongoingActivityDataHelperClass,
"mOngoingActivityLists");
if (!(list instanceof Iterable<?> iterable)) {
return false;
}
boolean sawItem = false;
for (Object data : iterable) {
sawItem = true;
if (!shouldHide(classifyOngoingData(data), ZONE_TOP)) {
return false;
}
}
return sawItem;
}
private void applyToViewTree(View root, boolean restoreOnly) {
ArrayDeque<View> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
View source = stack.pop();
if (source == null) {
continue;
}
if (source instanceof ViewGroup group) {
for (int index = group.getChildCount() - 1; index >= 0; index--) {
stack.push(group.getChildAt(index));
}
}
applySourceDecision(source, restoreOnly);
}
}
private boolean applySourceDecision(View source, boolean restoreOnly) {
if (source == null) {
return false;
}
if (restoreOnly) {
restoreIfForcedHidden(source);
return false;
}
int zone = detectZone(source);
if (zone == ZONE_NONE || !isSourceCandidate(source)) {
restoreIfForcedHidden(source);
return false;
}
View target = resolveHideTarget(source, zone);
if (target == null) {
restoreIfForcedHidden(source);
return false;
}
int targetZone = detectZone(target);
if (targetZone == ZONE_NONE) {
restoreIfForcedHidden(target);
return false;
}
String sourceText = collectText(source);
String targetText = target == source ? sourceText : collectText(target);
String kindText = sourceText.isEmpty() ? targetText : sourceText;
String classNames = target == source
? collectClassNames(source)
: collectClassNames(source) + " " + collectClassNames(target);
String lowerText = kindText.toLowerCase();
if (looksLikePrivacy(target, kindText) || isDefinitelyNotChipText(lowerText)) {
restoreIfForcedHidden(target);
return false;
}
int kind = classifyKind(source, kindText, classNames);
if (shouldHide(kind, targetZone)) {
forceHide(target);
return true;
} else {
restoreIfForcedHidden(target);
return false;
}
}
private boolean shouldPreemptivelyHide(View source) {
if (source == null) {
return false;
}
if (isForcedHidden(source) && shouldHideTopUnlocked()) {
return true;
}
if (!isTransitionSourceCandidate(source)) {
return false;
}
return shouldHideSource(source, true);
}
private boolean shouldHideSource(View source, boolean allowRelaxedGeometry) {
int zone = detectZone(source);
boolean relaxedGeometry = false;
if (zone == ZONE_NONE) {
if (!allowRelaxedGeometry || !isTransitionSourceCandidate(source)) {
return false;
}
if (source.getWidth() > 0 && source.getHeight() > 0) {
return false;
}
zone = ZONE_TOP;
relaxedGeometry = true;
} else if (!isSourceCandidate(source)) {
return false;
}
String sourceText = collectText(source);
String lowerText = sourceText.toLowerCase();
if (looksLikePrivacy(source, sourceText) || isDefinitelyNotChipText(lowerText)) {
return false;
}
int kind = classifyKind(source, sourceText, collectClassNames(source));
if (relaxedGeometry && kind == KIND_UNKNOWN && lowerText.isEmpty()) {
return false;
}
return shouldHide(kind, zone);
}
private boolean isSourceCandidate(View view) {
if (isOngoingActivityCapsule(view)) {
return false;
}
if (!isSystemUiView(view) || !looksLikeChipGeometry(view)) {
return false;
}
String className = className(view).toLowerCase();
if (className.contains("privacy") || isDefinitelyNotChipClass(className)) {
return false;
}
return isChipClassName(className);
}
private boolean isTransitionSourceCandidate(View view) {
if (isOngoingActivityCapsule(view)) {
return false;
}
if (!isSystemUiView(view)) {
return false;
}
String className = className(view).toLowerCase();
if (className.contains("privacy") || isDefinitelyNotChipClass(className)) {
return false;
}
return isChipClassName(className);
}
private View resolveHideTarget(View source, int sourceZone) {
String sourceClass = className(source).toLowerCase();
if (sourceClass.contains("touchinterceptframelayout")) {
return source;
}
View best = null;
View current = source;
int depth = 0;
int screenWidth = source.getResources().getDisplayMetrics().widthPixels;
while (current != null && depth < MAX_ASCENT) {
if (!isSystemUiView(current)) {
break;
}
if (detectZone(current) != sourceZone) {
break;
}
if (looksLikeHideTarget(current, source, screenWidth)) {
best = current;
}
ViewParent parent = current.getParent();
current = parent instanceof View view ? view : null;
depth++;
}
return best;
}
private boolean looksLikeHideTarget(View candidate, View source, int screenWidth) {
if (!looksLikeChipGeometry(candidate)) {
return false;
}
String className = className(candidate).toLowerCase();
if (className.contains("privacy") || isDefinitelyNotChipClass(className)) {
return false;
}
if (!isChipClassName(className)) {
return false;
}
int width = candidate.getWidth();
int height = candidate.getHeight();
if (width >= (int) (screenWidth * 0.98f) && height < dp(candidate, 90)) {
return false;
}
if (candidate == source) {
return true;
}
return candidate instanceof ViewGroup && width >= source.getWidth();
}
private boolean isChipClassName(String lowerClass) {
return lowerClass.contains("chip")
|| lowerClass.contains("ongoing")
|| lowerClass.contains("statusevent")
|| lowerClass.contains("touchinterceptframelayout");
}
private int detectZone(View view) {
if (view == null || view.getWidth() <= 0 || view.getHeight() <= 0) {
return ZONE_NONE;
}
int[] location = new int[2];
try {
view.getLocationOnScreen(location);
} catch (Throwable ignored) {
return ZONE_NONE;
}
return location[1] >= 0 && location[1] <= dp(view, TOP_REGION_DP) ? ZONE_TOP : ZONE_NONE;
}
private boolean shouldHide(int kind, int zone) {
if (zone != ZONE_TOP || !shouldHideTopUnlocked()) {
return false;
}
if (hideAll && kind == KIND_UNKNOWN) {
return true;
}
if (kind == KIND_MEDIA) {
return hideMediaUnlocked;
}
if (kind == KIND_NAV) {
return hideNavUnlocked;
}
if (kind == KIND_CALL) {
return hideCallUnlocked;
}
return false;
}
private boolean shouldHideTopUnlocked() {
return isAnyChipHidingEnabled() && !isKeyguardLocked();
}
private int classifyKind(View view, String text, String classNames) {
String lowerText = text != null ? text.toLowerCase() : "";
String lowerClass = classNames != null && !classNames.isEmpty()
? classNames.toLowerCase()
: (view != null ? className(view).toLowerCase() : "");
if (containsToken(lowerText, "call")
|| containsPhrase(lowerText, "ongoing call")
|| lowerClass.contains("call")) {
return KIND_CALL;
}
if (containsToken(lowerText, "maps")
|| containsTokenPrefix(lowerText, "navigat")
|| containsToken(lowerText, "towards")
|| containsToken(lowerText, "directions")
|| containsTokenPrefix(lowerText, "route")
|| containsToken(lowerText, "waze")
|| containsDistanceToken(lowerText)
|| containsAny(lowerClass, "map", "nav", "route")) {
return KIND_NAV;
}
if (containsToken(lowerText, "music")
|| containsToken(lowerText, "media")
|| containsTokenPrefix(lowerText, "play")
|| containsToken(lowerText, "spotify")
|| containsToken(lowerText, "song")
|| containsTokenPrefix(lowerText, "track")
|| containsToken(lowerText, "album")
|| containsToken(lowerText, "artist")
|| containsAny(lowerClass, "media", "music", "player")) {
return KIND_MEDIA;
}
if (lowerClass.contains("touchinterceptframelayout") && !lowerText.isEmpty()) {
return KIND_MEDIA;
}
return KIND_UNKNOWN;
}
private int classifyOngoingData(Object data) {
if (data == null) {
return KIND_UNKNOWN;
}
if (ReflectionSupport.getBooleanField(data, "mIsMediaOngoingData", false)) {
return KIND_MEDIA;
}
String text = (textField(data, "mPackageName") + " "
+ textField(data, "mPrimaryInfo") + " "
+ textField(data, "mSecondaryInfo") + " "
+ textField(data, "mDescription") + " "
+ textField(data, "mExpandedChipText")).trim();
return classifyKind(null, text, data.getClass().getName());
}
private boolean looksLikePrivacy(View view, String text) {
String lowerClass = className(view).toLowerCase();
String lowerText = text != null ? text.toLowerCase() : "";
return lowerClass.contains("privacy")
|| containsToken(lowerText, "camera")
|| containsToken(lowerText, "microphone")
|| containsPhrase(lowerText, "location in use")
|| containsPhrase(lowerText, "mic in use");
}
private String collectClassNames(View root) {
StringBuilder out = new StringBuilder();
appendClassName(out, root);
if (!(root instanceof ViewGroup)) {
return out.toString();
}
ArrayDeque<View> stack = new ArrayDeque<>();
stack.push(root);
int nodes = 0;
while (!stack.isEmpty() && out.length() < MAX_CLASS_SCAN_LEN && nodes < MAX_TEXT_NODES) {
View current = stack.pop();
nodes++;
if (current != root) {
appendClassName(out, current);
}
if (current instanceof ViewGroup group) {
for (int index = 0; index < group.getChildCount(); index++) {
stack.push(group.getChildAt(index));
}
}
}
return out.toString();
}
private String collectText(View root) {
StringBuilder out = new StringBuilder();
appendText(out, root.getContentDescription());
if (root instanceof TextView textView) {
appendText(out, textView.getText());
}
if (!(root instanceof ViewGroup)) {
return out.toString();
}
ArrayDeque<View> stack = new ArrayDeque<>();
stack.push(root);
int nodes = 0;
while (!stack.isEmpty() && out.length() < MAX_TEXT_SCAN_LEN && nodes < MAX_TEXT_NODES) {
View current = stack.pop();
nodes++;
if (current != root) {
appendText(out, current.getContentDescription());
if (current instanceof TextView textView) {
appendText(out, textView.getText());
}
}
if (current instanceof ViewGroup group) {
for (int index = 0; index < group.getChildCount(); index++) {
stack.push(group.getChildAt(index));
}
}
}
return out.toString();
}
private void forceHide(View view) {
int restoreVisibility = view.getVisibility() != View.GONE ? view.getVisibility() : View.VISIBLE;
markForcedHidden(view, restoreVisibility);
if (view.getVisibility() != View.GONE) {
view.setVisibility(View.GONE);
}
}
private void keepForcedHiddenViewsHidden() {
if (forcedHiddenViews.isEmpty()) {
return;
}
ArrayDeque<View> staleViews = new ArrayDeque<>();
for (View view : forcedHiddenViews) {
if (view == null || !view.isAttachedToWindow()) {
staleViews.add(view);
continue;
}
if (view.getVisibility() != View.GONE) {
view.setVisibility(View.GONE);
}
}
while (!staleViews.isEmpty()) {
forcedHiddenViews.remove(staleViews.removeFirst());
}
}
private void restoreForcedHiddenViews() {
if (forcedHiddenViews.isEmpty()) {
return;
}
ArrayDeque<View> views = new ArrayDeque<>(forcedHiddenViews);
while (!views.isEmpty()) {
restoreIfForcedHidden(views.removeFirst());
}
}
private void markForcedHidden(View view, int restoreVisibility) {
instanceStateStore.put(view, FIELD_PREV_VIS, restoreVisibility);
instanceStateStore.put(view, FIELD_FORCED_HIDDEN, Boolean.TRUE);
forcedHiddenViews.add(view);
}
private void restoreIfForcedHidden(View view) {
if (!isForcedHidden(view)) {
return;
}
Object previous = instanceStateStore.get(view, FIELD_PREV_VIS);
int restoreVisibility = previous instanceof Integer integer ? integer : View.VISIBLE;
instanceStateStore.remove(view, FIELD_FORCED_HIDDEN);
instanceStateStore.remove(view, FIELD_PREV_VIS);
forcedHiddenViews.remove(view);
if (view.getVisibility() != restoreVisibility) {
view.setVisibility(restoreVisibility);
}
if (restoreVisibility == View.VISIBLE && view.getAlpha() == 0f) {
view.setAlpha(1f);
}
}
private boolean isForcedHidden(View view) {
Object marker = instanceStateStore.get(view, FIELD_FORCED_HIDDEN);
return marker instanceof Boolean bool && bool;
}
private boolean isSystemUiView(View view) {
return className(view).contains("systemui");
}
private boolean looksLikeChipGeometry(View view) {
if (view == null || view.getWidth() <= 0 || view.getHeight() <= 0) {
return false;
}
int width = view.getWidth();
int height = view.getHeight();
int screenWidth = view.getResources().getDisplayMetrics().widthPixels;
if (height < dp(view, 18) || height > dp(view, 160)) {
return false;
}
if (width < height * 2) {
return false;
}
return !(width > (int) (screenWidth * 0.95f) && height < dp(view, 110));
}
private boolean isDefinitelyNotChipClass(String lowerClass) {
return lowerClass.contains("clock")
|| lowerClass.contains("date")
|| lowerClass.contains("brightness")
|| lowerClass.contains("tile")
|| lowerClass.contains("qsbuttonscontainer")
|| lowerClass.contains("sectionheaderview")
|| lowerClass.contains("expandablenotificationrow")
|| lowerClass.contains("carrier")
|| lowerClass.contains("statusicon")
|| lowerClass.contains("indicator")
|| lowerClass.contains("launchabletextview")
|| lowerClass.contains("qsshortendate")
|| lowerClass.contains("qsclock")
|| lowerClass.contains("quickqs");
}
private boolean isDefinitelyNotChipText(String lowerText) {
if (lowerText == null || lowerText.isEmpty()) {
return false;
}
return containsPhrase(lowerText, "notification settings")
|| containsToken(lowerText, "brightness")
|| containsToken(lowerText, "wifi")
|| containsPhrase(lowerText, "smart view")
|| containsToken(lowerText, "mirror");
}
private String className(View view) {
return view != null && view.getClass() != null ? view.getClass().getName() : "";
}
private String textField(Object target, String fieldName) {
Object value = ReflectionSupport.getFieldValue(target, fieldName);
return value != null ? String.valueOf(value) : "";
}
private boolean isOngoingActivityCapsule(View view) {
if (view == null || view.getId() == View.NO_ID) {
return false;
}
try {
return "ongoing_activity_capsule".equals(view.getResources().getResourceEntryName(view.getId()));
} catch (Throwable ignored) {
return false;
}
}
private void appendText(StringBuilder out, CharSequence value) {
if (value == null || value.length() == 0 || out.length() >= MAX_TEXT_SCAN_LEN) {
return;
}
if (out.length() > 0) {
out.append(' ');
}
int remaining = MAX_TEXT_SCAN_LEN - out.length();
if (value.length() > remaining) {
out.append(value, 0, remaining);
} else {
out.append(value);
}
}
private void appendClassName(StringBuilder out, View view) {
if (view == null || out.length() >= MAX_CLASS_SCAN_LEN) {
return;
}
String value = className(view);
if (value.isEmpty()) {
return;
}
if (out.length() > 0) {
out.append(' ');
}
int remaining = MAX_CLASS_SCAN_LEN - out.length();
if (value.length() > remaining) {
out.append(value, 0, remaining);
} else {
out.append(value);
}
}
private boolean containsAny(String value, String... needles) {
if (value == null || value.isEmpty()) {
return false;
}
for (String needle : needles) {
if (needle != null && value.contains(needle)) {
return true;
}
}
return false;
}
private boolean containsPhrase(String value, String phrase) {
return value != null && phrase != null && value.contains(phrase);
}
private boolean containsToken(String value, String token) {
if (value == null || value.isEmpty() || token == null || token.isEmpty()) {
return false;
}
String[] parts = value.split("[^a-z0-9]+");
for (String part : parts) {
if (token.equals(part)) {
return true;
}
}
return false;
}
private boolean containsTokenPrefix(String value, String prefix) {
if (value == null || value.isEmpty() || prefix == null || prefix.isEmpty()) {
return false;
}
String[] parts = value.split("[^a-z0-9]+");
for (String part : parts) {
if (part.startsWith(prefix)) {
return true;
}
}
return false;
}
private boolean containsDistanceToken(String value) {
return value != null && !value.isEmpty() && value.matches(".*\\b\\d+\\s*(m|km|mi|ft)\\b.*");
}
private int dp(View view, int dp) {
return Math.round(dp * view.getResources().getDisplayMetrics().density);
}
private Context getSystemContext() {
try {
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Object app = ReflectionSupport.invokeStaticMethod(
activityThreadClass,
"currentApplication",
new Class<?>[0]
);
return app instanceof Context context ? context : null;
} catch (Throwable ignored) {
return null;
}
}
private boolean isKeyguardLocked() {
try {
Context context = getSystemContext();
if (context == null) {
return false;
}
keyguardManager = getKeyguardManager(context);
return keyguardManager != null && keyguardManager.isKeyguardLocked();
} catch (Throwable ignored) {
return false;
}
}
private KeyguardManager getKeyguardManager(Context context) {
if (keyguardManager == null && context != null) {
Object service = context.getSystemService(Context.KEYGUARD_SERVICE);
if (service instanceof KeyguardManager manager) {
keyguardManager = manager;
}
}
return keyguardManager;
}
}
@@ -0,0 +1,40 @@
package se.ajpanton.statusbartweak.runtime.features.chips;
import io.github.libxposed.api.XposedModuleInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.features.RuntimeFeature;
/**
* Reserved home for stock status-chip suppression.
*
* Status chips appear to be largely independent of the custom icon-layout
* rebuild because they mostly suppress Samsung-owned chip views rather than
* participating in our future icon solver.
*/
public final class StatusChipsFeature implements RuntimeFeature {
private StatusChipHider statusChipHider;
@Override
public String getName() {
return "status-chips";
}
@Override
public void onPackageReady(
RuntimeContext context,
XposedModuleInterface.PackageReadyParam param
) {
if (param == null || !"com.android.systemui".equals(param.getPackageName())) {
return;
}
installController(context, param.getClassLoader());
}
private void installController(RuntimeContext context, ClassLoader classLoader) {
if (statusChipHider == null) {
statusChipHider = new StatusChipHider(context);
}
statusChipHider.installHooks(classLoader);
}
}
@@ -0,0 +1,337 @@
package se.ajpanton.statusbartweak.runtime.features.clock;
import android.content.Context;
import android.graphics.Color;
import android.text.TextUtils;
import java.util.ArrayList;
import java.util.Locale;
import se.ajpanton.statusbartweak.shell.settings.BatteryBarStyle;
final class ClockColorResolver {
String resolveColorHex(
Context context,
String expression,
int referenceColor,
int fallbackColor
) {
int resolved = resolveColor(context, expression, referenceColor, fallbackColor);
if (Color.alpha(resolved) >= 255) {
return String.format(Locale.ROOT, "#%06X", resolved & 0xFFFFFF);
}
return String.format(Locale.ROOT, "#%08X", resolved);
}
int resolveColor(
Context context,
String expression,
int referenceColor,
int fallbackColor
) {
if (TextUtils.isEmpty(expression)) {
return fallbackColor;
}
ArrayList<String> segments = splitSegments(expression);
if (segments.isEmpty() || segments.size() > 4) {
throw new IllegalArgumentException("Invalid colour expression");
}
int brightColor;
int darkColor;
switch (segments.size()) {
case 1:
brightColor = resolveSource(context, segments.get(0), referenceColor, fallbackColor);
darkColor = brightColor;
break;
case 2:
brightColor = resolveSource(context, segments.get(0), referenceColor, fallbackColor);
darkColor = resolveVariant(
context,
brightColor,
segments.get(1),
referenceColor,
fallbackColor);
break;
case 3:
int sharedSource = resolveSource(context, segments.get(0), referenceColor, fallbackColor);
brightColor = resolveVariant(
context,
sharedSource,
segments.get(1),
referenceColor,
fallbackColor);
darkColor = resolveVariant(
context,
sharedSource,
segments.get(2),
referenceColor,
fallbackColor);
break;
case 4:
int darkSource = resolveSource(context, segments.get(0), referenceColor, fallbackColor);
int brightSource = resolveSource(context, segments.get(1), referenceColor, fallbackColor);
brightColor = resolveVariant(
context,
brightSource,
segments.get(2),
referenceColor,
fallbackColor);
darkColor = resolveVariant(
context,
darkSource,
segments.get(3),
referenceColor,
fallbackColor);
break;
default:
throw new IllegalArgumentException("Unsupported colour expression");
}
return isDark(referenceColor) ? darkColor : brightColor;
}
private int resolveVariant(
Context context,
int sourceColor,
String spec,
int referenceColor,
int fallbackColor
) {
String value = spec != null ? spec.trim() : "";
if (value.isEmpty()) {
throw new IllegalArgumentException("Empty variant");
}
if (value.charAt(0) == '#') {
return resolveSource(context, value, referenceColor, fallbackColor);
}
return applyMathExpression(sourceColor, value);
}
private int resolveSource(
Context context,
String source,
int referenceColor,
int fallbackColor
) {
String value = source != null ? source.trim() : "";
if (value.isEmpty()) {
throw new IllegalArgumentException("Empty colour source");
}
if ("#batterybar".equalsIgnoreCase(value)) {
return BatteryBarStyle.resolveCurrentColor(context, referenceColor, fallbackColor);
}
return parseHexColor(value);
}
private int applyMathExpression(int sourceColor, String expression) {
String value = expression != null ? expression.trim() : "";
if (value.isEmpty()) {
throw new IllegalArgumentException("Empty colour math expression");
}
double[] rgb = new double[] {
Color.red(sourceColor),
Color.green(sourceColor),
Color.blue(sourceColor)
};
double alpha = Color.alpha(sourceColor);
int index = 0;
if (value.regionMatches(true, 0, "inv", 0, 3)) {
rgb[0] = 255d - rgb[0];
rgb[1] = 255d - rgb[1];
rgb[2] = 255d - rgb[2];
index = 3;
}
while (index < value.length()) {
while (index < value.length() && Character.isWhitespace(value.charAt(index))) {
index++;
}
if (index >= value.length()) {
break;
}
char operator = value.charAt(index);
if (operator != '+' && operator != '-' && operator != '*' && operator != '/') {
throw new IllegalArgumentException("Invalid colour operation");
}
index++;
while (index < value.length() && Character.isWhitespace(value.charAt(index))) {
index++;
}
if (index >= value.length()) {
throw new IllegalArgumentException("Missing colour operand");
}
int operandStart = index;
if (value.charAt(index) == '#') {
index++;
while (index < value.length() && isHexDigit(value.charAt(index))) {
index++;
}
} else {
while (index < value.length()) {
char c = value.charAt(index);
if (!Character.isDigit(c) && c != '.') {
break;
}
index++;
}
}
String operandText = value.substring(operandStart, index).trim();
if (operandText.isEmpty()) {
throw new IllegalArgumentException("Missing colour operand");
}
Operand operand = parseOperand(operandText);
for (int i = 0; i < 3; i++) {
double rhs = operand.rgb[i];
if (operand.isHex && (operator == '*' || operator == '/')) {
rhs = rhs / 255d;
}
rgb[i] = applyOperator(rgb[i], rhs, operator);
}
if (operand.hasAlpha) {
double rhs = operand.alpha;
if (operand.isHex && (operator == '*' || operator == '/')) {
rhs = rhs / 255d;
}
alpha = applyOperator(alpha, rhs, operator);
}
}
return Color.argb(
clampChannel(alpha),
clampChannel(rgb[0]),
clampChannel(rgb[1]),
clampChannel(rgb[2]));
}
private double applyOperator(double left, double right, char operator) {
switch (operator) {
case '+':
return left + right;
case '-':
return left - right;
case '*':
return left * right;
case '/':
return right == 0d ? left : left / right;
default:
throw new IllegalArgumentException("Unsupported operator");
}
}
private Operand parseOperand(String operandText) {
if (TextUtils.isEmpty(operandText)) {
throw new IllegalArgumentException("Missing operand");
}
if (operandText.charAt(0) == '#') {
return parseHexOperand(operandText);
}
double value;
try {
value = Double.parseDouble(operandText);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid numeric operand", e);
}
return new Operand(new double[] { value, value, value }, 0d, false, false);
}
private Operand parseHexOperand(String raw) {
String hex = expandHex(raw);
boolean hasAlpha = hex.length() == 8;
int offset = hasAlpha ? 2 : 0;
double alpha = hasAlpha ? parseHexByte(hex.substring(0, 2)) : 0d;
return new Operand(
new double[] {
parseHexByte(hex.substring(offset, offset + 2)),
parseHexByte(hex.substring(offset + 2, offset + 4)),
parseHexByte(hex.substring(offset + 4, offset + 6))
},
alpha,
hasAlpha,
true);
}
private int parseHexColor(String raw) {
String hex = expandHex(raw);
try {
return Color.parseColor("#" + hex);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid colour literal", e);
}
}
private String expandHex(String raw) {
String value = raw != null ? raw.trim() : "";
if (value.isEmpty() || value.charAt(0) != '#') {
throw new IllegalArgumentException("Expected colour literal");
}
String hex = value.substring(1);
if (hex.length() == 3 || hex.length() == 4) {
StringBuilder out = new StringBuilder(hex.length() * 2);
for (int i = 0; i < hex.length(); i++) {
out.append(hex.charAt(i)).append(hex.charAt(i));
}
hex = out.toString();
} else if (hex.length() != 6 && hex.length() != 8) {
throw new IllegalArgumentException("Unsupported colour literal length");
}
for (int i = 0; i < hex.length(); i++) {
if (!isHexDigit(hex.charAt(i))) {
throw new IllegalArgumentException("Invalid colour literal");
}
}
return hex.toUpperCase(Locale.ROOT);
}
private ArrayList<String> splitSegments(String expression) {
ArrayList<String> segments = new ArrayList<>();
if (expression == null) {
return segments;
}
StringBuilder current = new StringBuilder();
for (int i = 0; i < expression.length(); i++) {
char c = expression.charAt(i);
if (c == '|') {
segments.add(current.toString().trim());
current.setLength(0);
continue;
}
current.append(c);
}
segments.add(current.toString().trim());
return segments;
}
private boolean isDark(int color) {
double darkness = 1d
- ((0.299d * Color.red(color))
+ (0.587d * Color.green(color))
+ (0.114d * Color.blue(color))) / 255d;
return darkness > 0.5d;
}
private boolean isHexDigit(char c) {
return (c >= '0' && c <= '9')
|| (c >= 'a' && c <= 'f')
|| (c >= 'A' && c <= 'F');
}
private double parseHexByte(String raw) {
return Integer.parseInt(raw, 16);
}
private int clampChannel(double value) {
return Math.max(0, Math.min(255, (int) Math.round(value)));
}
private static final class Operand {
final double[] rgb;
final double alpha;
final boolean hasAlpha;
final boolean isHex;
Operand(double[] rgb, double alpha, boolean hasAlpha, boolean isHex) {
this.rgb = rgb;
this.alpha = alpha;
this.hasAlpha = hasAlpha;
this.isHex = isHex;
}
}
}
@@ -0,0 +1,40 @@
package se.ajpanton.statusbartweak.runtime.features.clock;
import io.github.libxposed.api.XposedModuleInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.features.RuntimeFeature;
/**
* First clock slice for the rebuild.
*
* For now this only targets the stock SystemUI clock while the custom Layout
* engine is off. Later, when the rewritten layout owns the clock container,
* this feature can hand off to that path instead of owning the stock view.
*/
public final class ClockFeature implements RuntimeFeature {
private StockClockController stockClockController;
@Override
public String getName() {
return "clock";
}
@Override
public void onPackageReady(
RuntimeContext context,
XposedModuleInterface.PackageReadyParam param
) {
if (param == null || !"com.android.systemui".equals(param.getPackageName())) {
return;
}
installController(context, param.getClassLoader());
}
private void installController(RuntimeContext context, ClassLoader classLoader) {
if (stockClockController == null) {
stockClockController = new StockClockController(context);
}
stockClockController.install(classLoader);
}
}
@@ -0,0 +1,188 @@
package se.ajpanton.statusbartweak.runtime.features.clock;
import android.content.Context;
import android.text.format.DateFormat;
import android.widget.TextView;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Locale;
import java.util.WeakHashMap;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
public final class ClockLayoutTextFactory {
public static final int ROW_GAP_AUTO = ClockPatternParser.ROW_GAP_AUTO;
private static final ClockPatternParser PATTERN_PARSER = new ClockPatternParser();
private static final ClockTextRenderer TEXT_RENDERER = new ClockTextRenderer();
private static final WeakHashMap<TextView, CachedModel> MODEL_CACHE = new WeakHashMap<>();
private ClockLayoutTextFactory() {
}
public static Model build(TextView source, SbtSettings settings) {
Context context = source != null ? source.getContext() : null;
if (context == null || settings == null) {
return Model.empty();
}
CacheKey cacheKey = cacheKey(context, source, settings);
CachedModel cached = MODEL_CACHE.get(source);
if (cached != null && cached.key.equals(cacheKey)) {
return cached.model;
}
Model model = buildUncached(context, source, settings);
MODEL_CACHE.put(source, new CachedModel(cacheKey, model));
return model;
}
private static Model buildUncached(Context context, TextView source, SbtSettings settings) {
if (!hasCustomClockTextEnabled(settings)) {
ArrayList<Row> rows = new ArrayList<>();
CharSequence text = source.getText();
rows.add(new Row(text, "", "", 0, 0));
CharSequence description = source.getContentDescription();
return new Model(rows, description != null ? description.toString() : String.valueOf(text), false);
}
if (settings.clockCustomFormatEnabled
&& settings.clockCustomFormat != null
&& !settings.clockCustomFormat.isEmpty()) {
ClockParsedPattern parsedPattern = PATTERN_PARSER.parse(settings.clockCustomFormat);
ArrayList<ClockTextRenderer.LayoutRowResult> renderedRows =
TEXT_RENDERER.renderLayoutRows(context, parsedPattern, source.getCurrentTextColor());
ArrayList<Row> rows = new ArrayList<>();
StringBuilder description = new StringBuilder();
for (int i = 0; i < renderedRows.size(); i++) {
ClockTextRenderer.LayoutRowResult row = renderedRows.get(i);
if (i > 0) {
description.append('\n');
}
description.append(row.contentDescription);
rows.add(new Row(
row.text,
row.leftText,
row.rightText,
row.pipeCount,
row.gapBeforePx));
}
return new Model(rows, description.toString(), parsedPattern.containsSeconds);
}
CharSequence text = renderFallbackClockText(context, settings);
ArrayList<Row> rows = new ArrayList<>();
rows.add(new Row(text, "", "", 0, 0));
return new Model(rows, text != null ? text.toString() : "", settings.clockShowSeconds);
}
private static CacheKey cacheKey(Context context, TextView source, SbtSettings settings) {
String pattern = settings.clockCustomFormat != null ? settings.clockCustomFormat : "";
boolean customPattern = settings.clockCustomFormatEnabled && !pattern.isEmpty();
boolean generatedText = hasCustomClockTextEnabled(settings);
boolean seconds = settings.clockShowSeconds
|| (customPattern && ClockMarkupSupport.containsSecondsPattern(pattern));
long divisor = seconds ? 1_000L : 60_000L;
CharSequence sourceText = source.getText();
CharSequence sourceDescription = source.getContentDescription();
return new CacheKey(
localeFor(context).toLanguageTag(),
System.currentTimeMillis() / divisor,
source.getCurrentTextColor(),
settings.clockShowSeconds,
settings.clockShowDatePrefix,
customPattern,
pattern,
!generatedText && sourceText != null ? sourceText.toString() : "",
!generatedText && sourceDescription != null ? sourceDescription.toString() : "");
}
private static boolean hasCustomClockTextEnabled(SbtSettings settings) {
if (settings == null) {
return false;
}
String pattern = settings.clockCustomFormat != null ? settings.clockCustomFormat : "";
return settings.clockShowSeconds
|| settings.clockShowDatePrefix
|| (settings.clockCustomFormatEnabled && !pattern.isEmpty());
}
private static CharSequence renderFallbackClockText(Context context, SbtSettings settings) {
Locale locale = localeFor(context);
Date now = new Date();
String timePattern = DateFormat.getBestDateTimePattern(
locale,
settings.clockShowSeconds ? "Hmss" : "Hm");
String timeText = new SimpleDateFormat(timePattern, locale).format(now);
if (!settings.clockShowDatePrefix) {
return timeText;
}
String datePattern = DateFormat.getBestDateTimePattern(locale, "EEE d MMM");
String dateText = new SimpleDateFormat(datePattern, locale).format(now);
return dateText + " " + timeText;
}
private static Locale localeFor(Context context) {
android.content.res.Configuration configuration = context.getResources().getConfiguration();
android.os.LocaleList locales = configuration.getLocales();
if (locales != null && !locales.isEmpty()) {
return locales.get(0);
}
return Locale.getDefault();
}
public static final class Model {
public final ArrayList<Row> rows;
public final String contentDescription;
public final boolean containsSeconds;
Model(ArrayList<Row> rows, String contentDescription, boolean containsSeconds) {
this.rows = rows != null ? rows : new ArrayList<>();
this.contentDescription = contentDescription != null ? contentDescription : "";
this.containsSeconds = containsSeconds;
}
static Model empty() {
return new Model(new ArrayList<>(), "", false);
}
public boolean isEmpty() {
return rows.isEmpty();
}
}
public static final class Row {
public final CharSequence text;
public final CharSequence leftText;
public final CharSequence rightText;
public final int pipeCount;
public final int gapBeforePx;
Row(
CharSequence text,
CharSequence leftText,
CharSequence rightText,
int pipeCount,
int gapBeforePx
) {
this.text = text != null ? text : "";
this.leftText = leftText != null ? leftText : "";
this.rightText = rightText != null ? rightText : "";
this.pipeCount = Math.max(0, pipeCount);
this.gapBeforePx = gapBeforePx;
}
}
private record CacheKey(
String locale,
long timeBucket,
int referenceColor,
boolean showSeconds,
boolean showDatePrefix,
boolean customPattern,
String pattern,
String sourceText,
String sourceDescription
) {
}
private record CachedModel(CacheKey key, Model model) {
}
}
@@ -0,0 +1,302 @@
package se.ajpanton.statusbartweak.runtime.features.clock;
import android.text.TextUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
final class ClockMarkupSupport {
private ClockMarkupSupport() {
}
enum TagCategory {
OTHER,
FONT_COLOR,
FONT_FACE
}
static final class OpenTag {
final String name;
final String openLiteral;
final String closeLiteral;
final TagCategory category;
OpenTag(String name, String openLiteral, String closeLiteral, TagCategory category) {
this.name = name;
this.openLiteral = openLiteral;
this.closeLiteral = closeLiteral;
this.category = category != null ? category : TagCategory.OTHER;
}
}
static final class TagState {
final ArrayList<OpenTag> stack = new ArrayList<>();
TagState() {
}
TagState(TagState other) {
if (other != null) {
stack.addAll(other.stack);
}
}
}
static final class ParsedTag {
final boolean closing;
final boolean selfClosing;
final String name;
final String literal;
final TagCategory category;
ParsedTag(
boolean closing,
boolean selfClosing,
String name,
String literal,
TagCategory category
) {
this.closing = closing;
this.selfClosing = selfClosing;
this.name = name != null ? name : "";
this.literal = literal != null ? literal : "";
this.category = category != null ? category : TagCategory.OTHER;
}
}
static void appendOpenTags(StringBuilder out, TagState state) {
for (OpenTag tag : state.stack) {
out.append(tag.openLiteral);
}
}
static void closeAllOpenTags(StringBuilder out, TagState state) {
for (int i = state.stack.size() - 1; i >= 0; i--) {
out.append(state.stack.get(i).closeLiteral);
}
state.stack.clear();
}
static void closeAllFontTags(StringBuilder out, TagState state) {
for (int i = state.stack.size() - 1; i >= 0; i--) {
OpenTag tag = state.stack.get(i);
if (tag.category != TagCategory.FONT_COLOR && tag.category != TagCategory.FONT_FACE) {
continue;
}
out.append(tag.closeLiteral);
state.stack.remove(i);
}
}
static void applyParsedTag(StringBuilder out, TagState state, ParsedTag parsedTag) {
if (TextUtils.isEmpty(parsedTag.name)) {
return;
}
if (parsedTag.selfClosing) {
if (out != null) {
out.append(parsedTag.literal);
}
return;
}
if (parsedTag.closing) {
closeTag(out, state, parsedTag.name);
return;
}
openTag(out, state, parsedTag.name, parsedTag.literal, parsedTag.category);
}
static void consumeTagLiteral(TagState state, String literal) {
ParsedTag parsedTag = parseTagLiteral(literal);
if (parsedTag == null) {
throw new IllegalArgumentException("Malformed tag");
}
applyParsedTag(null, state, parsedTag);
}
private static void openTag(
StringBuilder out,
TagState state,
String name,
String literal,
TagCategory category
) {
if (TextUtils.isEmpty(name) || TextUtils.isEmpty(literal)) {
return;
}
if (category == TagCategory.FONT_COLOR) {
closeLastCategory(out, state, TagCategory.FONT_COLOR);
} else if (category == TagCategory.FONT_FACE) {
closeLastCategory(out, state, TagCategory.FONT_FACE);
}
if (out != null) {
out.append(literal);
}
state.stack.add(new OpenTag(name, literal, "</" + name + ">", category));
}
private static void closeTag(StringBuilder out, TagState state, String name) {
if (TextUtils.isEmpty(name)) {
return;
}
String normalized = name.toLowerCase(Locale.ROOT);
if ("font".equals(normalized)) {
for (int i = state.stack.size() - 1; i >= 0; i--) {
OpenTag tag = state.stack.get(i);
if (!"font".equals(tag.name)) {
continue;
}
if (out != null) {
out.append(tag.closeLiteral);
}
state.stack.remove(i);
return;
}
throw new IllegalArgumentException("Unmatched closing tag");
}
for (int i = state.stack.size() - 1; i >= 0; i--) {
OpenTag tag = state.stack.get(i);
if (!normalized.equals(tag.name)) {
continue;
}
if (out != null) {
out.append(tag.closeLiteral);
}
state.stack.remove(i);
return;
}
throw new IllegalArgumentException("Unmatched closing tag");
}
private static void closeLastCategory(StringBuilder out, TagState state, TagCategory category) {
for (int i = state.stack.size() - 1; i >= 0; i--) {
OpenTag tag = state.stack.get(i);
if (tag.category != category) {
continue;
}
if (out != null) {
out.append(tag.closeLiteral);
}
state.stack.remove(i);
return;
}
}
static ParsedTag parseTagLiteral(String literal) {
if (TextUtils.isEmpty(literal)) {
return null;
}
String trimmed = literal.trim();
if (!trimmed.startsWith("<") || !trimmed.endsWith(">")) {
return null;
}
if (trimmed.matches("(?i)<\\s*br\\s*/?\\s*>")) {
return new ParsedTag(false, true, "br", trimmed, TagCategory.OTHER);
}
if (trimmed.matches("(?i)<\\s*/\\s*([a-z0-9_-]+)\\s*>")) {
String name = trimmed.replaceAll(
"(?i)<\\s*/\\s*([a-z0-9_-]+)\\s*>",
"$1").toLowerCase(Locale.ROOT);
return new ParsedTag(true, false, name, trimmed, TagCategory.OTHER);
}
if (trimmed.matches("(?i)<\\s*([a-z0-9_-]+)(?:\\s+[^>]*)?/\\s*>")) {
String name = trimmed.replaceAll(
"(?i)<\\s*([a-z0-9_-]+)(?:\\s+[^>]*)?/\\s*>",
"$1").toLowerCase(Locale.ROOT);
return new ParsedTag(false, true, name, trimmed, TagCategory.OTHER);
}
java.util.regex.Matcher matcher = java.util.regex.Pattern
.compile("(?i)<\\s*([a-z0-9_-]+)([^>]*)>")
.matcher(trimmed);
if (!matcher.matches()) {
return null;
}
String name = matcher.group(1).toLowerCase(Locale.ROOT);
String attributes = matcher.group(2) != null ? matcher.group(2) : "";
TagCategory category = TagCategory.OTHER;
if ("font".equals(name)) {
boolean hasColor = attributes.matches("(?is).*\\bcolor\\s*=\\s*['\"][^'\"]+['\"].*");
boolean hasFace = attributes.matches("(?is).*\\bface\\s*=\\s*['\"][^'\"]+['\"].*");
if (hasColor && !hasFace) {
category = TagCategory.FONT_COLOR;
} else if (hasFace && !hasColor) {
category = TagCategory.FONT_FACE;
}
} else if ("sbt-clock-color".equals(name)) {
category = TagCategory.FONT_COLOR;
}
return new ParsedTag(false, false, name, trimmed, category);
}
static List<Integer> findPipePositions(String markup) {
if (TextUtils.isEmpty(markup)) {
return Collections.emptyList();
}
ArrayList<Integer> positions = new ArrayList<>();
boolean insideTag = false;
boolean insideQuote = false;
for (int i = 0; i < markup.length(); i++) {
char c = markup.charAt(i);
if (insideTag) {
if (c == '>') {
insideTag = false;
}
continue;
}
if (c == '<') {
insideTag = true;
continue;
}
if (c == '\'') {
insideQuote = !insideQuote;
continue;
}
if (c == '|' && !insideQuote) {
positions.add(i);
}
}
return positions;
}
static boolean containsSecondsPattern(String pattern) {
if (TextUtils.isEmpty(pattern)) {
return false;
}
boolean insideTag = false;
boolean insideBlock = false;
boolean insideQuote = false;
for (int i = 0; i < pattern.length(); i++) {
char c = pattern.charAt(i);
if (insideTag) {
if (c == '>') {
insideTag = false;
}
continue;
}
if (insideBlock) {
if (c == '}') {
insideBlock = false;
}
continue;
}
if (c == '<') {
insideTag = true;
continue;
}
if (c == '{') {
insideBlock = true;
continue;
}
if (c == '\'') {
insideQuote = !insideQuote;
continue;
}
if (!insideQuote && c == 's') {
return true;
}
}
return false;
}
}
@@ -0,0 +1,104 @@
package se.ajpanton.statusbartweak.runtime.features.clock;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
final class ClockParsedPattern {
final List<ClockParsedRow> rows;
final boolean containsSeconds;
ClockParsedPattern(List<ClockParsedRow> rows, boolean containsSeconds) {
this.rows = rows != null
? Collections.unmodifiableList(new ArrayList<>(rows))
: Collections.emptyList();
this.containsSeconds = containsSeconds;
}
boolean isMultiline() {
return rows.size() > 1;
}
boolean hasPipeRows() {
for (ClockParsedRow row : rows) {
if (row != null && row.pipeCount > 0) {
return true;
}
}
return false;
}
static final class ClockParsedRow {
final String markup;
final int gapBeforePx;
final boolean syntaxError;
final int pipeCount;
final String leftSegmentMarkup;
final String middleSegmentMarkup;
final String rightSegmentMarkup;
ClockParsedRow(
String markup,
int gapBeforePx,
boolean syntaxError,
int pipeCount,
String leftSegmentMarkup,
String middleSegmentMarkup,
String rightSegmentMarkup
) {
this.markup = markup != null ? markup : "";
this.gapBeforePx = gapBeforePx;
this.syntaxError = syntaxError;
this.pipeCount = Math.max(0, pipeCount);
this.leftSegmentMarkup = leftSegmentMarkup != null ? leftSegmentMarkup : "";
this.middleSegmentMarkup = middleSegmentMarkup != null ? middleSegmentMarkup : "";
this.rightSegmentMarkup = rightSegmentMarkup != null ? rightSegmentMarkup : "";
}
static ClockParsedRow plain(String markup, int gapBeforePx) {
String safeMarkup = markup != null ? markup : "";
return new ClockParsedRow(
safeMarkup,
gapBeforePx,
false,
0,
safeMarkup,
"",
"");
}
static ClockParsedRow piped(
String markup,
int gapBeforePx,
int pipeCount,
String leftSegmentMarkup,
String middleSegmentMarkup,
String rightSegmentMarkup
) {
return new ClockParsedRow(
markup,
gapBeforePx,
false,
pipeCount,
leftSegmentMarkup,
middleSegmentMarkup,
rightSegmentMarkup);
}
static ClockParsedRow syntaxError(int gapBeforePx) {
return new ClockParsedRow(
"Syntax error",
gapBeforePx,
true,
0,
"Syntax error",
"",
"");
}
boolean hasPipeSplit() {
return pipeCount > 0;
}
}
}
@@ -0,0 +1,262 @@
package se.ajpanton.statusbartweak.runtime.features.clock;
import android.text.TextUtils;
import java.util.ArrayList;
final class ClockPatternParser {
static final int ROW_GAP_AUTO = Integer.MIN_VALUE;
ClockParsedPattern parse(String rawPattern) {
String pattern = rawPattern;
ArrayList<ClockParsedPattern.ClockParsedRow> rows = new ArrayList<>();
boolean containsSeconds = ClockMarkupSupport.containsSecondsPattern(pattern);
ClockMarkupSupport.TagState carriedState = new ClockMarkupSupport.TagState();
ClockMarkupSupport.TagState rowState = new ClockMarkupSupport.TagState(carriedState);
StringBuilder rowMarkup = new StringBuilder();
ClockMarkupSupport.appendOpenTags(rowMarkup, carriedState);
boolean rowSyntaxError = false;
int gapBeforePx = 0;
int index = 0;
while (index < pattern.length()) {
LineBreakToken lineBreak;
try {
lineBreak = tryParseLineBreak(pattern, index);
} catch (IllegalArgumentException e) {
rowSyntaxError = true;
int blockEnd = pattern.indexOf('}', index + 1);
index = blockEnd >= 0 ? blockEnd + 1 : pattern.length();
continue;
}
if (lineBreak != null) {
rows.add(finalizeRow(rowMarkup, rowState, gapBeforePx, rowSyntaxError));
carriedState = (lineBreak.resetPropagation || rowSyntaxError)
? new ClockMarkupSupport.TagState()
: new ClockMarkupSupport.TagState(rowState);
rowState = new ClockMarkupSupport.TagState(carriedState);
rowMarkup = new StringBuilder();
ClockMarkupSupport.appendOpenTags(rowMarkup, carriedState);
rowSyntaxError = false;
gapBeforePx = lineBreak.gapBeforePx;
index = lineBreak.endIndex;
continue;
}
if (rowSyntaxError) {
index++;
continue;
}
char c = pattern.charAt(index);
if (c == '{') {
int blockEnd = pattern.indexOf('}', index + 1);
if (blockEnd < 0) {
rowSyntaxError = true;
index = pattern.length();
continue;
}
String content = pattern.substring(index + 1, blockEnd);
try {
appendShorthandBlock(rowMarkup, rowState, content);
} catch (IllegalArgumentException e) {
rowSyntaxError = true;
}
index = blockEnd + 1;
continue;
}
if (c == '<') {
int tagEnd = pattern.indexOf('>', index + 1);
if (tagEnd < 0) {
rowSyntaxError = true;
index = pattern.length();
continue;
}
String literal = pattern.substring(index, tagEnd + 1);
try {
ClockMarkupSupport.ParsedTag parsedTag = ClockMarkupSupport.parseTagLiteral(literal);
if (parsedTag == null) {
throw new IllegalArgumentException("Malformed tag");
}
ClockMarkupSupport.applyParsedTag(rowMarkup, rowState, parsedTag);
} catch (IllegalArgumentException e) {
rowSyntaxError = true;
}
index = tagEnd + 1;
continue;
}
rowMarkup.append(c);
index++;
}
rows.add(finalizeRow(rowMarkup, rowState, gapBeforePx, rowSyntaxError));
return new ClockParsedPattern(rows, containsSeconds);
}
private ClockParsedPattern.ClockParsedRow finalizeRow(
StringBuilder rowMarkup,
ClockMarkupSupport.TagState rowState,
int gapBeforePx,
boolean rowSyntaxError
) {
if (rowSyntaxError) {
return ClockParsedPattern.ClockParsedRow.syntaxError(gapBeforePx);
}
StringBuilder balanced = new StringBuilder(rowMarkup);
ClockMarkupSupport.closeAllOpenTags(
balanced,
new ClockMarkupSupport.TagState(rowState));
return buildParsedRow(balanced.toString(), gapBeforePx);
}
private ClockParsedPattern.ClockParsedRow buildParsedRow(String markup, int gapBeforePx) {
if (TextUtils.isEmpty(markup)) {
return ClockParsedPattern.ClockParsedRow.plain("", gapBeforePx);
}
java.util.List<Integer> pipePositions = ClockMarkupSupport.findPipePositions(markup);
if (pipePositions.size() > 2) {
return ClockParsedPattern.ClockParsedRow.syntaxError(gapBeforePx);
}
if (pipePositions.isEmpty()) {
return ClockParsedPattern.ClockParsedRow.plain(markup, gapBeforePx);
}
if (pipePositions.size() == 1) {
int split = pipePositions.get(0);
return ClockParsedPattern.ClockParsedRow.piped(
markup,
gapBeforePx,
1,
markup.substring(0, split),
"",
markup.substring(split + 1));
}
int first = pipePositions.get(0);
int second = pipePositions.get(1);
return ClockParsedPattern.ClockParsedRow.piped(
markup,
gapBeforePx,
2,
markup.substring(0, first),
markup.substring(first + 1, second),
markup.substring(second + 1));
}
private void appendShorthandBlock(
StringBuilder out,
ClockMarkupSupport.TagState tagState,
String content
) {
String trimmed = content.trim();
if (trimmed.isEmpty()) {
return;
}
String[] tokens = trimmed.split("\\s+");
for (String token : tokens) {
if (TextUtils.isEmpty(token)) {
continue;
}
if ("/font".equalsIgnoreCase(token)) {
ClockMarkupSupport.closeAllFontTags(out, tagState);
continue;
}
if (token.charAt(0) == '/') {
String name = token.substring(1).trim();
if (name.isEmpty()) {
throw new IllegalArgumentException("Invalid shorthand closing tag");
}
ClockMarkupSupport.applyParsedTag(
out,
tagState,
new ClockMarkupSupport.ParsedTag(
true,
false,
name.toLowerCase(java.util.Locale.ROOT),
"</" + name + ">",
ClockMarkupSupport.TagCategory.OTHER));
continue;
}
if (token.charAt(0) == '#') {
String literal = "<sbt-clock-color value='"
+ android.text.TextUtils.htmlEncode(token)
+ "'>";
ClockMarkupSupport.applyParsedTag(
out,
tagState,
new ClockMarkupSupport.ParsedTag(
false,
false,
"sbt-clock-color",
literal,
ClockMarkupSupport.TagCategory.FONT_COLOR));
continue;
}
if (token.charAt(0) == '@' && token.length() > 1) {
String face = android.text.TextUtils.htmlEncode(token.substring(1));
String literal = "<font face='" + face + "'>";
ClockMarkupSupport.applyParsedTag(
out,
tagState,
new ClockMarkupSupport.ParsedTag(
false,
false,
"font",
literal,
ClockMarkupSupport.TagCategory.FONT_FACE));
continue;
}
String literal = "<" + token + ">";
ClockMarkupSupport.applyParsedTag(
out,
tagState,
new ClockMarkupSupport.ParsedTag(
false,
false,
token.toLowerCase(java.util.Locale.ROOT),
literal,
ClockMarkupSupport.TagCategory.OTHER));
}
}
private LineBreakToken tryParseLineBreak(String pattern, int index) {
if (pattern.startsWith("\\n", index)) {
return new LineBreakToken(index + 2, false, ROW_GAP_AUTO);
}
if (pattern.charAt(index) != '{') {
return null;
}
int end = pattern.indexOf('}', index + 1);
if (end < 0) {
return null;
}
String body = pattern.substring(index + 1, end).trim();
boolean resetPropagation = false;
if (body.startsWith("/")) {
resetPropagation = true;
body = body.substring(1).trim();
}
if (!body.startsWith("\\n")) {
return null;
}
String gapText = body.substring(2).trim();
int gapBeforePx = ROW_GAP_AUTO;
if (!gapText.isEmpty()) {
try {
gapBeforePx = Integer.parseInt(gapText);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid row gap", e);
}
}
return new LineBreakToken(end + 1, resetPropagation, gapBeforePx);
}
private static final class LineBreakToken {
final int endIndex;
final boolean resetPropagation;
final int gapBeforePx;
LineBreakToken(int endIndex, boolean resetPropagation, int gapBeforePx) {
this.endIndex = endIndex;
this.resetPropagation = resetPropagation;
this.gapBeforePx = gapBeforePx;
}
}
}
@@ -0,0 +1,607 @@
package se.ajpanton.statusbartweak.runtime.features.clock;
import android.content.Context;
import android.graphics.Color;
import android.text.Editable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.format.DateFormat;
import android.text.style.ForegroundColorSpan;
import androidx.core.text.HtmlCompat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.xml.sax.XMLReader;
final class ClockTextRenderer {
private static final String RESOLVED_COLOR_TAG_PREFIX = "sbt-color-";
private static final Pattern INTERNAL_COLOR_OPEN_TAG_PATTERN =
Pattern.compile("(?is)<\\s*sbt-clock-color\\b[^>]*\\bvalue\\s*=\\s*(['\"])(.*?)\\1[^>]*>");
private static final Pattern INTERNAL_COLOR_CLOSE_TAG_PATTERN =
Pattern.compile("(?is)<\\s*/\\s*sbt-clock-color\\s*>");
private static final Pattern FONT_COLOR_TAG_PATTERN =
Pattern.compile("(?i)<\\s*font\\b[^>]*\\bcolor\\s*=\\s*['\"](#(?:[0-9a-f]{8}|[0-9a-f]{6}))['\"][^>]*>");
private static final Pattern FONT_COLOR_ATTRIBUTE_PATTERN =
Pattern.compile("(?i)(<\\s*font\\b[^>]*\\bcolor\\s*=\\s*['\"])([^'\"]+)(['\"])");
private final ClockColorResolver colorResolver = new ClockColorResolver();
ArrayList<RowResult> renderRows(
Context context,
ClockParsedPattern parsedPattern,
int referenceColor
) {
ArrayList<RowResult> rowResults = new ArrayList<>();
if (parsedPattern.rows.isEmpty()) {
return rowResults;
}
Locale locale = localeFor(context);
Date now = new Date();
for (ClockParsedPattern.ClockParsedRow row : parsedPattern.rows) {
rowResults.add(renderRow(context, locale, now, row, referenceColor));
}
return rowResults;
}
ArrayList<LayoutRowResult> renderLayoutRows(
Context context,
ClockParsedPattern parsedPattern,
int referenceColor
) {
ArrayList<LayoutRowResult> rowResults = new ArrayList<>();
if (parsedPattern.rows.isEmpty()) {
return rowResults;
}
Locale locale = localeFor(context);
Date now = new Date();
for (ClockParsedPattern.ClockParsedRow row : parsedPattern.rows) {
rowResults.add(renderLayoutRow(context, locale, now, row, referenceColor));
}
return rowResults;
}
private LayoutRowResult renderLayoutRow(
Context context,
Locale locale,
Date now,
ClockParsedPattern.ClockParsedRow row,
int referenceColor
) {
if (row == null || row.syntaxError) {
return new LayoutRowResult("Syntax error", "", "", "Syntax error", 0, row != null ? row.gapBeforePx : 0);
}
try {
if (row.pipeCount <= 0) {
CharSequence text = formatMarkup(context, locale, now, row.markup, referenceColor);
return new LayoutRowResult(text, "", "", text != null ? text.toString() : "", 0, row.gapBeforePx);
}
ClockMarkupSupport.TagState tagState = new ClockMarkupSupport.TagState();
CharSequence left = renderSegmentWithCarry(
context,
locale,
now,
referenceColor,
row.leftSegmentMarkup,
tagState);
CharSequence middle = "";
if (!TextUtils.isEmpty(row.middleSegmentMarkup)) {
middle = renderSegmentWithCarry(
context,
locale,
now,
referenceColor,
row.middleSegmentMarkup,
tagState);
}
CharSequence right = renderSegmentWithCarry(
context,
locale,
now,
referenceColor,
row.rightSegmentMarkup,
tagState);
SpannableStringBuilder text = new SpannableStringBuilder();
if (left != null) {
text.append(left);
}
if (middle != null) {
text.append(middle);
}
if (right != null) {
text.append(right);
}
return new LayoutRowResult(
text,
left,
right,
text.toString(),
row.pipeCount,
row.gapBeforePx);
} catch (IllegalArgumentException e) {
return new LayoutRowResult("Syntax error", "", "", "Syntax error", 0, row.gapBeforePx);
}
}
private RowResult renderRow(
Context context,
Locale locale,
Date now,
ClockParsedPattern.ClockParsedRow row,
int referenceColor
) {
if (row == null || row.syntaxError) {
return new RowResult("Syntax error", "Syntax error", row != null ? row.gapBeforePx : 0);
}
try {
CharSequence display;
if (row.pipeCount <= 0) {
display = formatMarkup(context, locale, now, row.markup, referenceColor);
} else if (row.pipeCount == 1) {
display = renderSplitDisplay(
context,
locale,
now,
referenceColor,
row.leftSegmentMarkup,
null,
row.rightSegmentMarkup);
} else {
display = renderSplitDisplay(
context,
locale,
now,
referenceColor,
row.leftSegmentMarkup,
row.middleSegmentMarkup,
row.rightSegmentMarkup);
}
return new RowResult(display, display != null ? display.toString() : "", row.gapBeforePx);
} catch (IllegalArgumentException e) {
return new RowResult("Syntax error", "Syntax error", row.gapBeforePx);
}
}
private CharSequence renderSplitDisplay(
Context context,
Locale locale,
Date now,
int referenceColor,
String leftSegment,
String middleSegment,
String rightSegment
) {
ClockMarkupSupport.TagState tagState = new ClockMarkupSupport.TagState();
CharSequence left = renderSegmentWithCarry(
context,
locale,
now,
referenceColor,
leftSegment,
tagState);
CharSequence middle = "";
if (!TextUtils.isEmpty(middleSegment)) {
middle = renderSegmentWithCarry(
context,
locale,
now,
referenceColor,
middleSegment,
tagState);
}
CharSequence right = renderSegmentWithCarry(
context,
locale,
now,
referenceColor,
rightSegment,
tagState);
SpannableStringBuilder out = new SpannableStringBuilder();
if (left != null) {
out.append(left);
}
if (middle != null) {
out.append(middle);
}
if (right != null) {
out.append(right);
}
return out;
}
private CharSequence renderSegmentWithCarry(
Context context,
Locale locale,
Date now,
int referenceColor,
String segment,
ClockMarkupSupport.TagState tagState
) {
if (TextUtils.isEmpty(segment)) {
consumeSegmentTags(segment, tagState);
return "";
}
StringBuilder markup = new StringBuilder();
ClockMarkupSupport.appendOpenTags(markup, tagState);
markup.append(segment);
ClockMarkupSupport.TagState previewState = new ClockMarkupSupport.TagState(tagState);
consumeSegmentTags(segment, previewState);
ClockMarkupSupport.closeAllOpenTags(markup, new ClockMarkupSupport.TagState(previewState));
consumeSegmentTags(segment, tagState);
return formatMarkup(context, locale, now, markup.toString(), referenceColor);
}
private void consumeSegmentTags(String segment, ClockMarkupSupport.TagState tagState) {
if (TextUtils.isEmpty(segment)) {
return;
}
int index = 0;
while (index < segment.length()) {
int start = segment.indexOf('<', index);
if (start < 0) {
break;
}
int end = segment.indexOf('>', start + 1);
if (end < 0) {
throw new IllegalArgumentException("Malformed tag");
}
ClockMarkupSupport.consumeTagLiteral(tagState, segment.substring(start, end + 1));
index = end + 1;
}
}
private CharSequence formatMarkup(
Context context,
Locale locale,
Date now,
String markup,
int referenceColor
) {
if (TextUtils.isEmpty(markup)) {
return "";
}
if (markup.indexOf('<') < 0 || markup.indexOf('>') < markup.indexOf('<')) {
return new SimpleDateFormat(markup, locale).format(now);
}
String renderedHtml = formatPatternHtml(context, locale, now, markup, referenceColor);
CharSequence parsed = HtmlCompat.fromHtml(
renderedHtml,
HtmlCompat.FROM_HTML_MODE_LEGACY,
null,
this::handleCustomHtmlTag);
return applyAlphaAwareClockColours(parsed, renderedHtml);
}
private String formatPatternHtml(
Context context,
Locale locale,
Date now,
String markup,
int referenceColor
) {
StringBuilder out = new StringBuilder();
StringBuilder textChunk = new StringBuilder();
boolean insideTag = false;
StringBuilder tagChunk = new StringBuilder();
ArrayList<String> internalColorTagStack = new ArrayList<>();
for (int i = 0; i < markup.length(); i++) {
char c = markup.charAt(i);
if (insideTag) {
tagChunk.append(c);
if (c == '>') {
insideTag = false;
out.append(normalizeHtmlTag(
context,
tagChunk.toString(),
referenceColor,
internalColorTagStack));
tagChunk.setLength(0);
}
continue;
}
if (c == '<') {
if (textChunk.length() > 0) {
out.append(encodeHtmlTextPreservingSpaces(
new SimpleDateFormat(textChunk.toString(), locale).format(now)));
textChunk.setLength(0);
}
insideTag = true;
tagChunk.append(c);
continue;
}
textChunk.append(c);
}
if (insideTag) {
throw new IllegalArgumentException("Unterminated tag");
}
if (textChunk.length() > 0) {
out.append(encodeHtmlTextPreservingSpaces(
new SimpleDateFormat(textChunk.toString(), locale).format(now)));
}
return out.toString();
}
private String normalizeHtmlTag(
Context context,
String rawTag,
int referenceColor,
ArrayList<String> internalColorTagStack
) {
if (TextUtils.isEmpty(rawTag)) {
return "";
}
Matcher internalColorOpen = INTERNAL_COLOR_OPEN_TAG_PATTERN.matcher(rawTag);
if (internalColorOpen.matches()) {
String colorExpression = decodeHtmlAttribute(internalColorOpen.group(2));
String resolved = colorResolver.resolveColorHex(
context,
colorExpression,
referenceColor,
Color.WHITE);
String tagName = resolvedColorTagName(resolved);
internalColorTagStack.add(tagName);
return "<" + tagName + ">";
}
if (INTERNAL_COLOR_CLOSE_TAG_PATTERN.matcher(rawTag).matches()) {
if (internalColorTagStack.isEmpty()) {
throw new IllegalArgumentException("Unmatched colour tag");
}
String tagName = internalColorTagStack.remove(internalColorTagStack.size() - 1);
return "</" + tagName + ">";
}
Matcher matcher = FONT_COLOR_ATTRIBUTE_PATTERN.matcher(rawTag);
StringBuffer out = new StringBuffer();
while (matcher.find()) {
String resolved = colorResolver.resolveColorHex(
context,
matcher.group(2),
referenceColor,
Color.WHITE);
String replacement = matcher.group(1)
+ Matcher.quoteReplacement(resolved)
+ matcher.group(3);
matcher.appendReplacement(out, replacement);
}
matcher.appendTail(out);
return out.toString();
}
private String resolvedColorTagName(String resolvedColor) {
String hex = resolvedColor != null ? resolvedColor.trim() : "";
if (hex.startsWith("#")) {
hex = hex.substring(1);
}
if (hex.length() != 6 && hex.length() != 8) {
throw new IllegalArgumentException("Invalid resolved colour");
}
return RESOLVED_COLOR_TAG_PREFIX + hex.toLowerCase(Locale.ROOT);
}
private String decodeHtmlAttribute(String value) {
if (TextUtils.isEmpty(value)) {
return "";
}
return value
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&");
}
private void handleCustomHtmlTag(
boolean opening,
String tag,
Editable output,
XMLReader xmlReader
) {
Integer color = colorFromResolvedTag(tag);
if (color == null || output == null) {
return;
}
if (opening) {
output.setSpan(
new ColorMarker(color),
output.length(),
output.length(),
Spanned.SPAN_MARK_MARK);
} else {
endColorSpan(output);
}
}
private Integer colorFromResolvedTag(String tag) {
if (TextUtils.isEmpty(tag)) {
return null;
}
String normalized = tag.toLowerCase(Locale.ROOT);
if (!normalized.startsWith(RESOLVED_COLOR_TAG_PREFIX)) {
return null;
}
String hex = normalized.substring(RESOLVED_COLOR_TAG_PREFIX.length());
if (hex.length() != 6 && hex.length() != 8) {
return null;
}
try {
return Color.parseColor("#" + hex);
} catch (IllegalArgumentException e) {
return null;
}
}
private void endColorSpan(Editable output) {
ColorMarker marker = getLastSpan(output, ColorMarker.class);
if (marker == null) {
return;
}
int start = output.getSpanStart(marker);
int end = output.length();
output.removeSpan(marker);
if (start < 0 || start >= end) {
return;
}
output.setSpan(
new ForegroundColorSpan(marker.color),
start,
end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
private <T> T getLastSpan(Editable output, Class<T> kind) {
T[] spans = output.getSpans(0, output.length(), kind);
if (spans == null || spans.length == 0) {
return null;
}
T last = null;
int lastStart = -1;
for (T span : spans) {
int start = output.getSpanStart(span);
if (start >= lastStart) {
last = span;
lastStart = start;
}
}
return last;
}
private CharSequence applyAlphaAwareClockColours(CharSequence parsed, String renderedHtml) {
if (!(parsed instanceof Spanned) || TextUtils.isEmpty(renderedHtml)) {
return parsed;
}
ArrayList<Integer> colourSequence = new ArrayList<>();
Matcher matcher = FONT_COLOR_TAG_PATTERN.matcher(renderedHtml);
while (matcher.find()) {
try {
colourSequence.add(Color.parseColor(matcher.group(1)));
} catch (IllegalArgumentException ignored) {
}
}
if (colourSequence.isEmpty()) {
return parsed;
}
SpannableStringBuilder out = new SpannableStringBuilder(parsed);
ForegroundColorSpan[] spans = out.getSpans(0, out.length(), ForegroundColorSpan.class);
if (spans == null || spans.length == 0) {
return parsed;
}
Arrays.sort(spans, Comparator
.comparingInt((ForegroundColorSpan span) -> out.getSpanStart(span))
.thenComparingInt(span -> out.getSpanEnd(span)));
int count = Math.min(spans.length, colourSequence.size());
boolean changed = false;
for (int i = 0; i < count; i++) {
int color = colourSequence.get(i);
if (Color.alpha(color) >= 255) {
continue;
}
ForegroundColorSpan span = spans[i];
int start = out.getSpanStart(span);
int end = out.getSpanEnd(span);
int flags = out.getSpanFlags(span);
if (start < 0 || end <= start) {
continue;
}
out.removeSpan(span);
out.setSpan(new ForegroundColorSpan(color), start, end, flags);
changed = true;
}
return changed ? out : parsed;
}
private String encodeHtmlTextPreservingSpaces(String text) {
if (TextUtils.isEmpty(text)) {
return "";
}
String encoded = TextUtils.htmlEncode(text);
StringBuilder out = new StringBuilder(encoded.length());
int index = 0;
while (index < encoded.length()) {
char c = encoded.charAt(index);
if (c != ' ') {
out.append(c);
index++;
continue;
}
int runStart = index;
while (index < encoded.length() && encoded.charAt(index) == ' ') {
index++;
}
int runLength = index - runStart;
boolean leading = runStart == 0;
boolean trailing = index == encoded.length();
if (!leading && !trailing && runLength == 1) {
out.append(' ');
continue;
}
for (int i = 0; i < runLength; i++) {
out.append("&nbsp;");
}
}
return out.toString();
}
private Locale localeFor(Context context) {
android.content.res.Configuration configuration = context.getResources().getConfiguration();
android.os.LocaleList locales = configuration.getLocales();
if (locales != null && !locales.isEmpty()) {
return locales.get(0);
}
return Locale.getDefault();
}
static final class RowResult {
final CharSequence text;
final String contentDescription;
final int gapBeforePx;
RowResult(CharSequence text, String contentDescription, int gapBeforePx) {
this.text = text != null ? text : "";
this.contentDescription = contentDescription != null ? contentDescription : "";
this.gapBeforePx = gapBeforePx;
}
}
static final class LayoutRowResult {
final CharSequence text;
final CharSequence leftText;
final CharSequence rightText;
final String contentDescription;
final int pipeCount;
final int gapBeforePx;
LayoutRowResult(
CharSequence text,
CharSequence leftText,
CharSequence rightText,
String contentDescription,
int pipeCount,
int gapBeforePx
) {
this.text = text != null ? text : "";
this.leftText = leftText != null ? leftText : "";
this.rightText = rightText != null ? rightText : "";
this.contentDescription = contentDescription != null ? contentDescription : "";
this.pipeCount = Math.max(0, pipeCount);
this.gapBeforePx = gapBeforePx;
}
}
private static final class ColorMarker {
final int color;
ColorMarker(int color) {
this.color = color;
}
}
}
@@ -0,0 +1,773 @@
package se.ajpanton.statusbartweak.runtime.features.clock;
import android.app.KeyguardManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import android.text.style.UnderlineSpan;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroupOverlay;
import android.view.ViewParent;
import android.view.ViewTreeObserver;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import io.github.libxposed.api.XposedInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
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.settings.RuntimeSettingsCache;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
final class StockClockController {
private static final String CLOCK_CLASS_NAME =
"com.android.systemui.statusbar.policy.QSClockIndicatorView";
private final RuntimeContext runtimeContext;
private final Set<TextView> trackedClocks =
Collections.newSetFromMap(new WeakHashMap<>());
private final Set<TextView> ownedClocks =
Collections.newSetFromMap(new WeakHashMap<>());
private final Map<TextView, Runnable> tickers = new WeakHashMap<>();
private final Map<TextView, ClockRowsOverlay> rowOverlays = new WeakHashMap<>();
private final Map<TextView, ViewTreeObserver.OnPreDrawListener> rowOverlaySyncListeners =
new WeakHashMap<>();
private final Map<TextView, StockClockSnapshot> stockSnapshots = new WeakHashMap<>();
private boolean hooksInstalled;
private boolean receiverRegistered;
private boolean layoutModeListenerRegistered;
StockClockController(RuntimeContext runtimeContext) {
this.runtimeContext = runtimeContext;
}
void install(ClassLoader classLoader) {
if (hooksInstalled || classLoader == null) {
return;
}
registerLayoutModeListenerIfNeeded();
Class<?> clockClass = ReflectionSupport.requireClass(CLOCK_CLASS_NAME, classLoader);
XposedInterface framework = runtimeContext.getFramework();
XposedHookSupport.hookAllMethodsIfExists(framework, clockClass, "onAttachedToWindow", chain -> {
Object result = chain.proceed();
Object target = chain.getThisObject();
if (target instanceof TextView clockView) {
trackAndApply(clockView, null);
}
return result;
});
XposedHookSupport.hookAllMethodsIfExists(framework, clockClass, "onDetachedFromWindow", chain -> {
Object result = chain.proceed();
Object target = chain.getThisObject();
if (target instanceof TextView clockView) {
untrackClock(clockView);
}
return result;
});
XposedHookSupport.hookAllMethodsIfExists(framework, clockClass, "setVisibility", chain -> {
Object result = chain.proceed();
Object target = chain.getThisObject();
if (target instanceof TextView) {
TextView clockView = (TextView) target;
if (clockView.getVisibility() == View.VISIBLE) {
trackAndApply(clockView, null);
}
if (rowOverlays.containsKey(clockView)) {
syncRowOverlayVisuals(clockView);
}
}
return result;
});
XposedHookSupport.hookAllMethodsIfExists(framework, clockClass, "setAlpha", chain -> {
Object result = chain.proceed();
Object target = chain.getThisObject();
if (target instanceof TextView clockView && rowOverlays.containsKey(clockView)) {
syncRowOverlayVisuals(clockView);
}
return result;
});
XposedHookSupport.hookAllMethods(framework, clockClass, "notifyTimeChanged", chain -> {
Object result = chain.proceed();
Object target = chain.getThisObject();
if (!(target instanceof TextView)) {
return result;
}
TextView clockView = (TextView) target;
java.util.List<Object> args = chain.getArgs();
Object bellSound = args != null && !args.isEmpty()
? args.get(0)
: null;
captureBellSoundSnapshot(clockView, bellSound);
if (isDemoBellSound(bellSound)) {
stopTicker(clockView);
return result;
}
trackAndApply(clockView, bellSound);
return result;
});
XposedHookSupport.hookAllMethodsIfExists(framework, clockClass, "setTextColor", chain -> {
Object result = chain.proceed();
Object target = chain.getThisObject();
if (target instanceof TextView) {
TextView clockView = (TextView) target;
if (hasCustomClockTextEnabled(RuntimeSettingsCache.get(clockView.getContext()))
|| ownedClocks.contains(clockView)) {
applyClockText(clockView);
}
}
return result;
});
XposedHookSupport.hookAllMethodsIfExists(framework, clockClass, "onDarkChanged", chain -> {
Object result = chain.proceed();
Object target = chain.getThisObject();
if (target instanceof TextView) {
TextView clockView = (TextView) target;
if (hasCustomClockTextEnabled(RuntimeSettingsCache.get(clockView.getContext()))
|| ownedClocks.contains(clockView)) {
applyClockText(clockView);
}
}
return result;
});
hooksInstalled = true;
}
private void registerLayoutModeListenerIfNeeded() {
if (layoutModeListenerRegistered) {
return;
}
runtimeContext.getModeStateRepository().addLayoutEnabledListener(enabled -> {
if (enabled) {
removeAllRowOverlays();
} else {
refreshAllClocks();
}
});
layoutModeListenerRegistered = true;
}
private void trackAndApply(TextView clockView, Object bellSound) {
trackedClocks.add(clockView);
registerReceiverIfNeeded(clockView.getContext());
if (bellSound != null) {
captureBellSoundSnapshot(clockView, bellSound);
} else {
captureCurrentStockSnapshot(clockView);
}
applyClockText(clockView);
}
private void untrackClock(TextView clockView) {
trackedClocks.remove(clockView);
ownedClocks.remove(clockView);
removeRowOverlay(clockView);
stopRowOverlayVisualSync(clockView);
stockSnapshots.remove(clockView);
stopTicker(clockView);
}
private void registerReceiverIfNeeded(Context context) {
if (context == null || receiverRegistered) {
return;
}
Context appContext = context.getApplicationContext();
if (appContext == null) {
appContext = context;
}
BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context ctx, Intent intent) {
refreshAllClocks();
}
};
IntentFilter filter = new IntentFilter();
filter.addAction(SbtSettings.ACTION_SETTINGS_CHANGED);
filter.addAction(Intent.ACTION_USER_UNLOCKED);
filter.addAction(Intent.ACTION_BATTERY_CHANGED);
filter.addAction(Intent.ACTION_TIME_TICK);
filter.addAction(Intent.ACTION_TIME_CHANGED);
filter.addAction(Intent.ACTION_TIMEZONE_CHANGED);
filter.addAction(Intent.ACTION_LOCALE_CHANGED);
filter.addAction(Intent.ACTION_CONFIGURATION_CHANGED);
if (Build.VERSION.SDK_INT >= 33) {
appContext.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED);
} else {
appContext.registerReceiver(receiver, filter);
}
receiverRegistered = true;
}
private void refreshAllClocks() {
for (TextView clockView : new ArrayList<>(trackedClocks)) {
applyClockText(clockView);
}
}
private void removeAllRowOverlays() {
for (TextView clockView : new ArrayList<>(trackedClocks)) {
removeRowOverlay(clockView);
}
}
private void applyClockText(TextView clockView) {
SbtSettings settings = RuntimeSettingsCache.get(clockView.getContext());
if (!hasCustomClockTextEnabled(settings)) {
stopTicker(clockView);
if (ownedClocks.contains(clockView)) {
restoreStockText(clockView);
ownedClocks.remove(clockView);
}
return;
}
captureCurrentStockSnapshot(clockView);
ClockLayoutTextFactory.Model model = ClockLayoutTextFactory.build(clockView, settings);
ArrayList<ClockTextRenderer.LayoutRowResult> renderedRows = layoutRowsFromModel(model);
ClockTextRenderer.LayoutRowResult lastRow = renderedRows.isEmpty()
? new ClockTextRenderer.LayoutRowResult("", "", "", "", 0, 0)
: renderedRows.get(renderedRows.size() - 1);
CharSequence renderedText = lastRow.text;
String contentDescription = model.contentDescription;
boolean useSecondsTicker = model.containsSeconds;
applyClockLineMode(clockView);
if (!sameTextSignature(clockView.getText(), renderedText)) {
clockView.setText(renderedText);
}
applyRowOverlay(clockView, renderedRows);
if (!TextUtils.equals(clockView.getContentDescription(), contentDescription)) {
clockView.setContentDescription(contentDescription);
}
ownedClocks.add(clockView);
if (useSecondsTicker) {
startTicker(clockView);
} else {
stopTicker(clockView);
}
}
private ArrayList<ClockTextRenderer.LayoutRowResult> layoutRowsFromModel(
ClockLayoutTextFactory.Model model
) {
ArrayList<ClockTextRenderer.LayoutRowResult> rows = new ArrayList<>();
if (model == null || model.rows == null) {
return rows;
}
for (ClockLayoutTextFactory.Row row : model.rows) {
rows.add(new ClockTextRenderer.LayoutRowResult(
row.text,
row.leftText,
row.rightText,
row.text != null ? row.text.toString() : "",
row.pipeCount,
row.gapBeforePx));
}
return rows;
}
private void restoreStockText(TextView clockView) {
StockClockSnapshot snapshot = stockSnapshots.get(clockView);
if (snapshot == null) {
return;
}
removeRowOverlay(clockView);
applyClockLineMode(clockView);
if (!sameTextSignature(clockView.getText(), snapshot.text)) {
clockView.setText(snapshot.text);
}
if (!TextUtils.equals(clockView.getContentDescription(), snapshot.contentDescription)) {
clockView.setContentDescription(snapshot.contentDescription);
}
}
private void captureCurrentStockSnapshot(TextView clockView) {
if (ownedClocks.contains(clockView)) {
return;
}
stockSnapshots.put(clockView, new StockClockSnapshot(
clockView.getText(),
stringValue(clockView.getContentDescription())));
}
private void captureBellSoundSnapshot(TextView clockView, Object bellSound) {
if (bellSound == null) {
return;
}
String text = stringValue(ReflectionSupport.getFieldValue(bellSound, "TimeText"));
String description = stringValue(ReflectionSupport.getFieldValue(
bellSound,
"TimeContentDescription"));
if (text == null && description == null) {
return;
}
stockSnapshots.put(clockView, new StockClockSnapshot(text, description));
}
private boolean hasCustomClockTextEnabled(SbtSettings settings) {
if (settings == null) {
return false;
}
String pattern = settings.clockCustomFormat != null ? settings.clockCustomFormat : "";
return settings.clockShowSeconds
|| settings.clockShowDatePrefix
|| (settings.clockCustomFormatEnabled && !pattern.isEmpty());
}
private void applyClockLineMode(TextView clockView) {
clockView.setSingleLine(true);
clockView.setHorizontallyScrolling(false);
clockView.setMaxLines(1);
}
private void applyRowOverlay(
TextView clockView,
ArrayList<ClockTextRenderer.LayoutRowResult> rows
) {
if (rows == null
|| rows.size() <= 1
|| runtimeContext.getModeStateRepository().isLayoutEnabled()
|| !isUnlockedForOverlay(clockView.getContext())) {
removeRowOverlay(clockView);
stopRowOverlayVisualSync(clockView);
return;
}
ViewGroup host = findOverlayHost(clockView);
if (host == null) {
removeRowOverlay(clockView);
stopRowOverlayVisualSync(clockView);
return;
}
disableAncestorClipping(host);
ClockRowsOverlay overlay = rowOverlays.get(clockView);
if (overlay == null || overlay.host != host) {
removeRowOverlay(clockView);
overlay = new ClockRowsOverlay(host);
rowOverlays.put(clockView, overlay);
}
overlay.update(clockView, rows);
ensureRowOverlayVisualSync(clockView);
}
private boolean isUnlockedForOverlay(Context context) {
if (isKeyguardLocked(context)) {
return false;
}
SceneKey activeScene = runtimeContext.getModeStateRepository().getActiveScene();
return activeScene != null && activeScene.isUnlocked();
}
private boolean isKeyguardLocked(Context context) {
if (context == null) {
return false;
}
try {
KeyguardManager keyguardManager = context.getSystemService(KeyguardManager.class);
return keyguardManager != null && keyguardManager.isKeyguardLocked();
} catch (RuntimeException e) {
return false;
}
}
private void disableAncestorClipping(View view) {
Object parent = view;
int depth = 0;
while (parent instanceof ViewGroup && depth < 12) {
ViewGroup group = (ViewGroup) parent;
group.setClipChildren(false);
group.setClipToPadding(false);
group.setClipToOutline(false);
parent = group.getParent();
depth++;
}
}
private ViewGroup findOverlayHost(TextView clockView) {
android.view.ViewParent parent = clockView.getParent();
ViewGroup best = null;
int depth = 0;
while (parent instanceof ViewGroup && depth < 12) {
ViewGroup group = (ViewGroup) parent;
best = group;
parent = group.getParent();
depth++;
}
return best;
}
private void removeRowOverlay(TextView clockView) {
ClockRowsOverlay overlay = rowOverlays.remove(clockView);
if (overlay != null) {
overlay.remove();
}
stopRowOverlayVisualSync(clockView);
}
private void ensureRowOverlayVisualSync(TextView clockView) {
if (clockView == null || rowOverlaySyncListeners.containsKey(clockView)) {
return;
}
ViewTreeObserver observer = clockView.getViewTreeObserver();
if (observer == null || !observer.isAlive()) {
return;
}
ViewTreeObserver.OnPreDrawListener listener = () -> {
syncRowOverlayVisuals(clockView);
return true;
};
observer.addOnPreDrawListener(listener);
rowOverlaySyncListeners.put(clockView, listener);
}
private void stopRowOverlayVisualSync(TextView clockView) {
ViewTreeObserver.OnPreDrawListener listener = rowOverlaySyncListeners.remove(clockView);
if (listener == null) {
return;
}
ViewTreeObserver observer = clockView.getViewTreeObserver();
if (observer != null && observer.isAlive()) {
observer.removeOnPreDrawListener(listener);
}
}
private void syncRowOverlayVisuals(TextView clockView) {
ClockRowsOverlay overlay = rowOverlays.get(clockView);
if (overlay == null) {
stopRowOverlayVisualSync(clockView);
return;
}
overlay.syncFromSource(clockView);
}
private void startTicker(TextView clockView) {
Runnable ticker = tickers.get(clockView);
if (ticker == null) {
ticker = new Runnable() {
@Override
public void run() {
if (clockView.getHandler() == null || !clockView.isAttachedToWindow()) {
stopTicker(clockView);
return;
}
applyClockText(clockView);
scheduleNextTick(clockView, this);
}
};
tickers.put(clockView, ticker);
}
clockView.removeCallbacks(ticker);
scheduleNextTick(clockView, ticker);
}
private void stopTicker(TextView clockView) {
Runnable ticker = tickers.get(clockView);
if (ticker != null) {
clockView.removeCallbacks(ticker);
}
}
private void scheduleNextTick(TextView clockView, Runnable ticker) {
long now = System.currentTimeMillis();
long next = now - (now % 1000L) + 1000L;
clockView.postDelayed(ticker, Math.max(1L, next - now));
}
private boolean isDemoBellSound(Object bellSound) {
if (bellSound == null) {
return false;
}
Object demoMode = ReflectionSupport.getFieldValue(bellSound, "demo");
return demoMode instanceof Boolean && (Boolean) demoMode;
}
private boolean sameTextSignature(CharSequence left, CharSequence right) {
return TextUtils.equals(buildTextSignature(left), buildTextSignature(right));
}
private String buildTextSignature(CharSequence text) {
if (text == null) {
return "<null>";
}
StringBuilder out = new StringBuilder();
out.append(text);
if (!(text instanceof Spanned)) {
return out.toString();
}
Spanned spanned = (Spanned) text;
Object[] spans = spanned.getSpans(0, spanned.length(), Object.class);
if (spans == null || spans.length == 0) {
return out.toString();
}
Arrays.sort(spans, Comparator
.comparingInt((Object span) -> spanned.getSpanStart(span))
.thenComparingInt(span -> spanned.getSpanEnd(span))
.thenComparing(span -> span.getClass().getName()));
for (Object span : spans) {
int start = spanned.getSpanStart(span);
int end = spanned.getSpanEnd(span);
int flags = spanned.getSpanFlags(span);
out.append('|')
.append(span.getClass().getName())
.append('@')
.append(start)
.append(':')
.append(end)
.append(':')
.append(flags);
if (span instanceof ForegroundColorSpan) {
out.append(':').append(((ForegroundColorSpan) span).getForegroundColor());
} else if (span instanceof TypefaceSpan) {
out.append(':').append(((TypefaceSpan) span).getFamily());
} else if (span instanceof RelativeSizeSpan) {
out.append(':').append(((RelativeSizeSpan) span).getSizeChange());
} else if (span instanceof StyleSpan) {
out.append(':').append(((StyleSpan) span).getStyle());
} else if (span instanceof UnderlineSpan) {
out.append(":u");
}
}
return out.toString();
}
private String stringValue(Object value) {
return value != null ? String.valueOf(value) : null;
}
private static final class StockClockSnapshot {
final CharSequence text;
final String contentDescription;
StockClockSnapshot(CharSequence text, String contentDescription) {
this.text = text != null ? text : "";
this.contentDescription = contentDescription != null ? contentDescription : "";
}
}
private final class ClockRowsOverlay {
final ViewGroup host;
private final ViewGroupOverlay overlay;
private final ArrayList<TextView> rowViews = new ArrayList<>();
private ArrayList<ClockTextRenderer.LayoutRowResult> lastRows;
private boolean removed;
ClockRowsOverlay(ViewGroup host) {
this.host = host;
this.overlay = host.getOverlay();
}
void update(TextView source, ArrayList<ClockTextRenderer.LayoutRowResult> rows) {
if (removed
|| rows.size() <= 1) {
remove();
return;
}
lastRows = new ArrayList<>(rows);
if (!source.isAttachedToWindow() || source.getBaseline() < 0 || source.getWidth() <= 0) {
source.post(() -> {
if (!removed) {
update(source, rows);
}
});
return;
}
int visibleRows = rows.size() - 1;
ensureViewCount(source, visibleRows);
int[] sourceLocation = new int[2];
int[] hostLocation = new int[2];
source.getLocationOnScreen(sourceLocation);
host.getLocationOnScreen(hostLocation);
int sourceBaselineY = sourceLocation[1] + source.getBaseline();
ArrayList<RowMetrics> rowMetrics = measureRows(source, rows);
int anchorOffset = alignmentOffset(rowMetrics.get(rows.size() - 1), rowMetrics);
int cumulativeOffset = 0;
for (int rowIndex = rows.size() - 2; rowIndex >= 0; rowIndex--) {
ClockTextRenderer.LayoutRowResult nextRow = rows.get(rowIndex + 1);
cumulativeOffset += resolveGap(nextRow != null ? nextRow.gapBeforePx : 0, source);
TextView rowView = rowViews.get(rowIndex);
ClockTextRenderer.LayoutRowResult row = rows.get(rowIndex);
rowView.setText(row != null ? row.text : "");
copyTextViewStyle(source, rowView);
positionRowView(
source,
rowView,
sourceLocation[0] - hostLocation[0]
+ alignmentOffset(rowMetrics.get(rowIndex), rowMetrics)
- anchorOffset,
sourceBaselineY - hostLocation[1] - cumulativeOffset);
}
}
void syncFromSource(TextView source) {
if (lastRows == null || lastRows.size() <= 1) {
remove();
return;
}
update(source, lastRows);
}
private ArrayList<RowMetrics> measureRows(
TextView source,
ArrayList<ClockTextRenderer.LayoutRowResult> rows
) {
ArrayList<RowMetrics> metrics = new ArrayList<>();
TextView probe = new TextView(source.getContext());
probe.setLayoutParams(new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT));
copyTextViewStyle(source, probe);
for (ClockTextRenderer.LayoutRowResult row : rows) {
metrics.add(measureRow(probe, row));
}
return metrics;
}
private RowMetrics measureRow(
TextView probe,
ClockTextRenderer.LayoutRowResult row
) {
if (row == null || row.pipeCount <= 0) {
return new RowMetrics(0, 0, 0);
}
int width = measureTextWidth(probe, row.text);
int leftPipe = measureTextWidth(probe, row.leftText);
int rightPipe = width - measureTextWidth(probe, row.rightText);
int pipePosition = row.pipeCount > 1
? Math.round((leftPipe + rightPipe) / 2f)
: leftPipe;
return new RowMetrics(width, pipePosition, row.pipeCount);
}
private int measureTextWidth(TextView probe, CharSequence text) {
probe.setText(text != null ? text : "");
int widthSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
probe.measure(widthSpec, heightSpec);
return Math.max(0, probe.getMeasuredWidth());
}
private int alignmentOffset(RowMetrics row, ArrayList<RowMetrics> rows) {
if (row == null || row.pipeCount <= 0) {
return 0;
}
int maxPipePosition = 0;
for (RowMetrics candidate : rows) {
if (candidate != null && candidate.pipeCount > 0) {
maxPipePosition = Math.max(maxPipePosition, candidate.pipePosition);
}
}
return maxPipePosition - row.pipePosition;
}
void remove() {
removed = true;
lastRows = null;
for (TextView view : rowViews) {
overlay.remove(view);
}
rowViews.clear();
}
private void ensureViewCount(TextView source, int count) {
while (rowViews.size() > count) {
TextView view = rowViews.remove(rowViews.size() - 1);
overlay.remove(view);
}
while (rowViews.size() < count) {
TextView view = new TextView(source.getContext());
view.setBackgroundColor(android.graphics.Color.TRANSPARENT);
view.setIncludeFontPadding(source.getIncludeFontPadding());
view.setSingleLine(true);
view.setMaxLines(1);
view.setHorizontallyScrolling(true);
view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
rowViews.add(view);
overlay.add(view);
}
}
private void copyTextViewStyle(TextView source, TextView target) {
target.setTextColor(source.getCurrentTextColor());
target.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, source.getTextSize());
target.setTypeface(source.getTypeface());
target.setGravity(source.getGravity());
target.setIncludeFontPadding(source.getIncludeFontPadding());
target.setPadding(
source.getPaddingLeft(),
source.getPaddingTop(),
source.getPaddingRight(),
source.getPaddingBottom());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
target.setLetterSpacing(source.getLetterSpacing());
target.setFontFeatureSettings(source.getFontFeatureSettings());
}
VisualState visualState = resolveVisualState(source);
target.setVisibility(visualState.visible ? View.VISIBLE : View.INVISIBLE);
target.setAlpha(visualState.alpha);
}
int rowCount() {
return rowViews.size();
}
private VisualState resolveVisualState(TextView source) {
float alpha = 1f;
boolean visible = source.isShown();
View current = source;
while (current != null && current != host) {
if (current.getVisibility() != View.VISIBLE) {
visible = false;
}
alpha *= current.getAlpha();
ViewParent parent = current.getParent();
current = parent instanceof View ? (View) parent : null;
}
return new VisualState(visible, alpha);
}
private void positionRowView(
TextView source,
TextView rowView,
int left,
int baselineY
) {
int widthSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
rowView.measure(widthSpec, heightSpec);
int baseline = Math.max(0, rowView.getBaseline());
int top = baselineY - baseline;
rowView.layout(left, top, left + rowView.getMeasuredWidth(), top + rowView.getMeasuredHeight());
}
private int resolveGap(int gapBeforePx, TextView source) {
if (gapBeforePx == ClockPatternParser.ROW_GAP_AUTO) {
return Math.max(1, source.getLineHeight());
}
return gapBeforePx;
}
private record VisualState(boolean visible, float alpha) {
}
private record RowMetrics(int width, int pipePosition, int pipeCount) {
}
}
}
@@ -0,0 +1,173 @@
package se.ajpanton.statusbartweak.runtime.features.debug;
import android.app.Notification;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;
import java.lang.reflect.Method;
import io.github.libxposed.api.XposedInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
final class DebugNotificationAutoGroupController {
private static final String CLASS_GROUP_HELPER =
"com.android.server.notification.GroupHelper";
private static final String CLASS_NOTIFICATION_RECORD =
"com.android.server.notification.NotificationRecord";
private static final String CLASS_NOTIFICATION_MANAGER_SERVICE =
"com.android.server.notification.NotificationManagerService";
private static final String EXTRA_DEBUG_NOTIFICATION = "sbt_debug_notification";
private final RuntimeContext runtimeContext;
private boolean hooksInstalled;
DebugNotificationAutoGroupController(RuntimeContext runtimeContext) {
this.runtimeContext = runtimeContext;
}
void install(ClassLoader classLoader) {
if (hooksInstalled) {
return;
}
Class<?> groupHelper = ReflectionSupport.findClassIfExists(CLASS_GROUP_HELPER, classLoader);
Class<?> recordClass = ReflectionSupport.findClassIfExists(CLASS_NOTIFICATION_RECORD, classLoader);
Class<?> nmsClass = ReflectionSupport.findClassIfExists(
CLASS_NOTIFICATION_MANAGER_SERVICE,
classLoader);
if (groupHelper == null || recordClass == null || nmsClass == null) {
runtimeContext.logWarning("Debug notification autogroup hook target missing", null);
return;
}
XposedInterface.Hooker blockDebugGrouping = chain -> {
if (chain.getArgs().isEmpty() || !isOurNotificationRecord(chain.getArg(0))) {
return chain.proceed();
}
if (chain.getExecutable() instanceof Method method
&& method.getReturnType() == Boolean.TYPE) {
return false;
}
return null;
};
hookIfExists(groupHelper, "onNotificationPosted", blockDebugGrouping);
hookIfExists(groupHelper, "onNotificationRemoved", blockDebugGrouping);
hookIfExists(groupHelper, "maybeGroup", blockDebugGrouping);
hookIfExists(groupHelper, "maybeUngroup", blockDebugGrouping);
hookIfExists(groupHelper, "autoGroup", blockDebugGrouping);
hookIfExists(groupHelper, "autogroup", blockDebugGrouping);
hookIfExists(groupHelper, "maybeAutoGroup", blockDebugGrouping);
hookIfExists(groupHelper, "shouldAutoGroup", blockDebugGrouping);
hookIfExists(groupHelper, "isEligibleForAutoGroup", blockDebugGrouping);
hookIfExists(recordClass, "setOverrideGroupKey", chain -> {
if (isOurNotificationRecord(chain.getThisObject())) {
return null;
}
return chain.proceed();
});
XposedInterface.Hooker dropAutoSummary = chain -> {
NotificationInfo info = extractNotificationInfo(chain.getArgs().toArray());
if (info != null
&& SbtSettings.MODULE_PACKAGE.equals(info.packageName)
&& !info.isDebugNotification) {
return null;
}
return chain.proceed();
};
hookIfExists(nmsClass, "enqueueNotificationInternal", dropAutoSummary);
hookIfExists(nmsClass, "enqueueNotificationInternalLocked", dropAutoSummary);
hooksInstalled = true;
}
private void hookIfExists(Class<?> type, String methodName, XposedInterface.Hooker hooker) {
for (Method method : type.getDeclaredMethods()) {
if (!methodName.equals(method.getName())) {
continue;
}
method.setAccessible(true);
runtimeContext.getFramework().hook(method).intercept(hooker);
}
}
private NotificationInfo extractNotificationInfo(Object[] args) {
StatusBarNotification sbn = null;
Notification notification = null;
String packageName = null;
for (Object arg : args) {
if (arg instanceof StatusBarNotification statusBarNotification) {
sbn = statusBarNotification;
packageName = sbn.getPackageName();
notification = sbn.getNotification();
break;
}
if (arg instanceof Notification candidateNotification && notification == null) {
notification = candidateNotification;
}
if (arg instanceof String candidatePackage && packageName == null) {
packageName = candidatePackage;
}
if (arg != null && arg.getClass().getName().contains("NotificationRecord")) {
Object sbnObj = ReflectionSupport.invokeMethod(arg, "getSbn", new Class<?>[0]);
if (sbnObj instanceof StatusBarNotification statusBarNotification) {
sbn = statusBarNotification;
packageName = sbn.getPackageName();
notification = sbn.getNotification();
break;
}
Object packageObj = ReflectionSupport.invokeMethod(
arg,
"getPackageName",
new Class<?>[0]);
if (packageObj instanceof String stringPackage) {
packageName = stringPackage;
}
Object notificationObj = ReflectionSupport.invokeMethod(
arg,
"getNotification",
new Class<?>[0]);
if (notificationObj instanceof Notification candidateNotification) {
notification = candidateNotification;
}
}
}
if (notification == null && sbn != null) {
notification = sbn.getNotification();
}
if (packageName == null || notification == null) {
return null;
}
Bundle extras = notification.extras;
boolean isDebug = extras != null && extras.getBoolean(EXTRA_DEBUG_NOTIFICATION, false);
return new NotificationInfo(packageName, isDebug);
}
private boolean isOurNotificationRecord(Object record) {
if (record == null) {
return false;
}
Object sbnObj = ReflectionSupport.invokeMethod(record, "getSbn", new Class<?>[0]);
if (sbnObj instanceof StatusBarNotification sbn) {
return SbtSettings.MODULE_PACKAGE.equals(sbn.getPackageName());
}
if (sbnObj != null) {
Object packageObj = ReflectionSupport.invokeMethod(
sbnObj,
"getPackageName",
new Class<?>[0]);
if (packageObj instanceof String packageName) {
return SbtSettings.MODULE_PACKAGE.equals(packageName);
}
}
Object packageObj = ReflectionSupport.invokeMethod(record, "getPackageName", new Class<?>[0]);
return packageObj instanceof String packageName
&& SbtSettings.MODULE_PACKAGE.equals(packageName);
}
private record NotificationInfo(String packageName, boolean isDebugNotification) {
}
}
@@ -0,0 +1,25 @@
package se.ajpanton.statusbartweak.runtime.features.debug;
import io.github.libxposed.api.XposedModuleInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.features.RuntimeFeature;
public final class DebugNotificationAutoGroupFeature implements RuntimeFeature {
private DebugNotificationAutoGroupController controller;
@Override
public String getName() {
return "debug-notification-autogroup";
}
@Override
public void onSystemServerStarting(
RuntimeContext context,
XposedModuleInterface.SystemServerStartingParam param
) {
if (controller == null) {
controller = new DebugNotificationAutoGroupController(context);
}
controller.install(param.getClassLoader());
}
}
@@ -0,0 +1,346 @@
package se.ajpanton.statusbartweak.runtime.features.misc;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;
import java.text.NumberFormat;
import java.util.HashSet;
import java.util.Set;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport;
import se.ajpanton.statusbartweak.runtime.hooks.XposedHookSupport;
import se.ajpanton.statusbartweak.runtime.settings.RuntimeSettingsCache;
final class AodBottomBatteryFallbackController {
private static final int OVERRIDE_TAG_KEY = 0x7f0b0a01;
private static final int VISIBILITY_TAG_KEY = 0x7f0b0a02;
private static final long[] INDICATION_RETRY_DELAYS_MS = new long[] {96L, 224L, 480L};
private final RuntimeContext runtimeContext;
private Object currentManager;
private FrameLayout currentHost;
private final Set<Runnable> pendingRetries = new HashSet<>();
AodBottomBatteryFallbackController(RuntimeContext runtimeContext) {
this.runtimeContext = runtimeContext;
}
void install(ClassLoader classLoader) {
hookSetBottomArea(classLoader);
hookDozing(classLoader);
hookBatteryRefresh(classLoader);
}
private void hookSetBottomArea(ClassLoader classLoader) {
Class<?> callbackClass = ReflectionSupport.findClassIfExists(
"com.android.systemui.doze.PluginAODManager$6",
classLoader
);
if (callbackClass == null) {
return;
}
XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), callbackClass, "setBottomArea", chain -> {
Object result = chain.proceed();
View addedView = chain.getArgs().isEmpty() && !(result instanceof View)
? null
: (chain.getArgs().isEmpty() ? null : (chain.getArg(0) instanceof View view ? view : null));
currentManager = resolveManager(chain.getThisObject());
currentHost = resolveBottomDozeAreaFromManager(currentManager);
updateFallback(currentHost, addedView);
return result;
});
}
private void hookDozing(ClassLoader classLoader) {
Class<?> managerClass = ReflectionSupport.findClassIfExists(
"com.android.systemui.doze.PluginAODManager",
classLoader
);
if (managerClass == null) {
return;
}
XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), managerClass, "setIsDozing", chain -> {
Object result = chain.proceed();
currentManager = chain.getThisObject();
currentHost = resolveBottomDozeAreaFromManager(currentManager);
boolean dozing = chain.getArgs().size() > 0
&& chain.getArg(0) instanceof Boolean bool
&& bool;
View target = currentHost != null && currentHost.getChildCount() > 0
? currentHost.getChildAt(0)
: null;
if (!dozing) {
clearAll(currentHost, target);
return result;
}
updateFallback(currentHost, target);
return result;
});
}
private void hookBatteryRefresh(ClassLoader classLoader) {
Class<?> callbackClass = ReflectionSupport.findClassIfExists(
"com.android.systemui.statusbar.KeyguardSecIndicationController$SecKeyguardCallback",
classLoader
);
if (callbackClass == null) {
return;
}
XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), callbackClass, "onRefreshBatteryInfo", chain -> {
Object result = chain.proceed();
if (currentManager == null || currentHost == null) {
return result;
}
View target = currentHost.getChildCount() > 0 ? currentHost.getChildAt(0) : null;
updateFallback(currentHost, target);
return result;
});
}
private void updateFallback(FrameLayout host, View addedView) {
if (host == null) {
return;
}
BatteryStatusAccess.BatteryState batteryState = resolveVisibleBatteryState(host);
if (batteryState == null) {
clearAll(host, addedView);
return;
}
CharSequence text = buildBatteryPercentageText(batteryState);
if (text == null || text.length() == 0) {
clearAll(host, addedView);
return;
}
if (canApplyIndicationOnlyFallback(host, addedView)) {
clearIndicationOnlyFallback(addedView, host);
cancelRetries(host);
applyIndicationOnlyFallback(host, addedView, text.toString());
scheduleIndicationOnlyRetries(host, addedView, text.toString());
return;
}
clearAll(host, addedView);
}
private void clearAll(FrameLayout host, View addedView) {
cancelRetries(host);
clearIndicationOnlyFallback(addedView, host);
}
private BatteryStatusAccess.BatteryState resolveVisibleBatteryState(View host) {
if (host == null) {
return null;
}
if (!RuntimeSettingsCache.get(host.getContext()).batteryAodUnpluggedPercent) {
return null;
}
BatteryStatusAccess.BatteryState state =
BatteryStatusAccess.getCurrentBatteryState(host.getContext());
if (state == null || state.plugged() || state.charged()) {
return null;
}
return state;
}
private CharSequence buildBatteryPercentageText(BatteryStatusAccess.BatteryState state) {
if (state == null || state.level() < 0) {
return null;
}
return NumberFormat.getPercentInstance().format(state.level() / 100.0d);
}
private boolean canApplyIndicationOnlyFallback(FrameLayout host, View addedView) {
Object component = getChargingComponent(resolvePluginRoot(addedView, host));
return resolveNativeTextView(component, "d") != null;
}
private boolean applyIndicationOnlyFallback(FrameLayout host, View addedView, String text) {
Object component = getChargingComponent(resolvePluginRoot(addedView, host));
if (component == null) {
return false;
}
TextView indicationText = resolveNativeTextView(component, "d");
if (indicationText == null) {
return false;
}
TextView remainingText = resolveNativeTextView(component, "k");
View batteryContainer = resolveNativeBatteryContainer(component);
View hostView = resolveNativeHostView(component);
View infoHolder = getObjectFieldAs(hostView, "b", View.class);
View usbContainer = getObjectFieldAs(hostView, "l", View.class);
rememberAndApplyText(indicationText, text);
rememberAndApplyVisibility(indicationText, View.VISIBLE);
rememberAndApplyVisibility(remainingText, View.GONE);
rememberAndApplyVisibility(batteryContainer, View.GONE);
rememberAndApplyVisibility(usbContainer, View.GONE);
forceViewChainVisible(indicationText);
forceViewChainVisible(infoHolder);
forceViewChainVisible(hostView);
return true;
}
private void clearIndicationOnlyFallback(View addedView, FrameLayout host) {
Object component = getChargingComponent(resolvePluginRoot(addedView, host));
if (component == null) {
return;
}
restoreRememberedText(resolveNativeTextView(component, "d"));
restoreRememberedVisibility(resolveNativeTextView(component, "k"));
restoreRememberedVisibility(resolveNativeBatteryContainer(component));
restoreRememberedVisibility(getObjectFieldAs(resolveNativeHostView(component), "l", View.class));
}
private void scheduleIndicationOnlyRetries(FrameLayout host, View addedView, String text) {
for (long delayMs : INDICATION_RETRY_DELAYS_MS) {
Runnable retry = new Runnable() {
@Override
public void run() {
pendingRetries.remove(this);
if (currentManager == null || currentHost != host) {
return;
}
if (resolveVisibleBatteryState(host) == null) {
clearAll(host, addedView);
return;
}
applyIndicationOnlyFallback(host, addedView, text);
}
};
pendingRetries.add(retry);
host.postDelayed(retry, delayMs);
}
}
private void cancelRetries(FrameLayout host) {
if (host == null) {
pendingRetries.clear();
return;
}
for (Runnable pending : pendingRetries) {
host.removeCallbacks(pending);
}
pendingRetries.clear();
}
private View resolvePluginRoot(View source, FrameLayout host) {
if (source instanceof ViewGroup) {
return source;
}
if (host != null && host.getChildCount() > 0) {
return host.getChildAt(0);
}
return null;
}
private Object getChargingComponent(Object root) {
return BatteryStatusAccess.getObjectField(root, "p");
}
private TextView resolveNativeTextView(Object component, String fieldName) {
View hostView = resolveNativeHostView(component);
return getObjectFieldAs(hostView, fieldName, TextView.class);
}
private ViewGroup resolveNativeBatteryContainer(Object component) {
View hostView = resolveNativeHostView(component);
return getObjectFieldAs(hostView, "c", ViewGroup.class);
}
private View resolveNativeHostView(Object component) {
return getObjectFieldAs(component, "c", View.class);
}
private void rememberAndApplyText(TextView view, String text) {
if (view == null) {
return;
}
if (!(view.getTag(OVERRIDE_TAG_KEY) instanceof Object[])) {
view.setTag(OVERRIDE_TAG_KEY, new Object[] {view.getText(), view.getVisibility()});
}
view.setText(text);
view.setVisibility(View.VISIBLE);
}
private void restoreRememberedText(TextView view) {
if (view == null) {
return;
}
Object tag = view.getTag(OVERRIDE_TAG_KEY);
if (!(tag instanceof Object[] saved)) {
return;
}
view.setText(saved.length > 0 && saved[0] instanceof CharSequence sequence ? sequence : "");
view.setVisibility(saved.length > 1 && saved[1] instanceof Integer integer ? integer : View.VISIBLE);
view.setTag(OVERRIDE_TAG_KEY, null);
}
private void rememberAndApplyVisibility(View view, int visibility) {
if (view == null) {
return;
}
if (!(view.getTag(VISIBILITY_TAG_KEY) instanceof Integer)) {
view.setTag(VISIBILITY_TAG_KEY, view.getVisibility());
}
view.setVisibility(visibility);
}
private void restoreRememberedVisibility(View view) {
if (view == null) {
return;
}
Object tag = view.getTag(VISIBILITY_TAG_KEY);
if (!(tag instanceof Integer integer)) {
return;
}
view.setVisibility(integer);
view.setTag(VISIBILITY_TAG_KEY, null);
}
private void forceViewChainVisible(View view) {
View current = view;
while (current != null) {
current.setVisibility(View.VISIBLE);
current.setAlpha(1f);
current.setTranslationX(0f);
current.setTranslationY(0f);
current.setScaleX(1f);
current.setScaleY(1f);
current.requestLayout();
current.invalidate();
Object parent = current.getParent();
current = parent instanceof View next ? next : null;
}
}
private Object resolveManager(Object callback) {
return BatteryStatusAccess.getObjectField(callback, "this$0");
}
private FrameLayout resolveBottomDozeAreaFromManager(Object manager) {
if (manager == null) {
return null;
}
try {
Object panelLazy = ReflectionSupport.getFieldValue(manager, "mPanelViewControllerLazy");
Object panel = ReflectionSupport.invokeMethod(panelLazy, "get", new Class<?>[0]);
Object bottomController = ReflectionSupport.getFieldValue(panel, "mKeyguardSecBottomAreaViewController");
Object bottomView = ReflectionSupport.invokeMethod(bottomController, "getView", new Class<?>[0]);
Object delegate = ReflectionSupport.getFieldValue(bottomView, "bottomDozeArea$delegate");
Object host = ReflectionSupport.invokeMethod(delegate, "getValue", new Class<?>[0]);
return host instanceof FrameLayout frameLayout ? frameLayout : null;
} catch (Throwable ignored) {
return null;
}
}
private <T> T getObjectFieldAs(Object target, String fieldName, Class<T> cls) {
Object value = BatteryStatusAccess.getObjectField(target, fieldName);
return cls.isInstance(value) ? cls.cast(value) : null;
}
}
@@ -0,0 +1,218 @@
package se.ajpanton.statusbartweak.runtime.features.misc;
import android.content.Context;
import android.media.AudioManager;
import android.telecom.TelecomManager;
import android.telephony.TelephonyManager;
import io.github.libxposed.api.XposedInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport;
import se.ajpanton.statusbartweak.runtime.hooks.XposedHookSupport;
import se.ajpanton.statusbartweak.runtime.settings.RuntimeSettingsCache;
final class AodCallVisibilityController {
private static final int PLUGIN_KEY_PHONE_STATE = 0x2;
private final RuntimeContext runtimeContext;
AodCallVisibilityController(RuntimeContext runtimeContext) {
this.runtimeContext = runtimeContext;
}
void install(ClassLoader classLoader) {
hookSuppressAmbientDisplay(classLoader);
hookFrontScrim(classLoader);
hookAlwaysOnSuppressedChanged(classLoader);
hookDozeMachineStateOverrides(classLoader);
hookPluginPhoneStateReadOverride(classLoader);
}
private void hookSuppressAmbientDisplay(ClassLoader classLoader) {
hookBooleanVoidMethod(
classLoader,
"com.android.systemui.statusbar.phone.CentralSurfacesCommandQueueCallbacks",
"suppressAmbientDisplay"
);
hookBooleanVoidMethod(
classLoader,
"com.android.systemui.statusbar.CommandQueue",
"suppressAmbientDisplay"
);
}
private void hookFrontScrim(ClassLoader classLoader) {
hookBooleanVoidMethod(
classLoader,
"com.android.systemui.statusbar.phone.SecLsScrimControlHelper",
"setFrontScrimToBlack"
);
}
private void hookAlwaysOnSuppressedChanged(ClassLoader classLoader) {
hookBooleanVoidMethod(
classLoader,
"com.android.systemui.doze.DozeSuppressor$1",
"onAlwaysOnSuppressedChanged"
);
hookBooleanVoidMethod(
classLoader,
"com.android.systemui.doze.AODUi$1",
"onAlwaysOnSuppressedChanged"
);
}
private void hookDozeMachineStateOverrides(ClassLoader classLoader) {
Class<?> dozeMachineClass = ReflectionSupport.findClassIfExists(
"com.android.systemui.doze.DozeMachine",
classLoader
);
Class<?> stateClass = ReflectionSupport.findClassIfExists(
"com.android.systemui.doze.DozeMachine$State",
classLoader
);
if (dozeMachineClass == null || stateClass == null) {
return;
}
XposedInterface framework = runtimeContext.getFramework();
XposedHookSupport.hookAllMethods(framework, dozeMachineClass, "requestState", chain -> {
Object state = chain.getArgs().isEmpty() ? null : chain.getArg(0);
if (!(state instanceof Enum<?> target) || !shouldForceAodForCall()) {
return chain.proceed();
}
Enum<?> replacement = overrideDozeState(target);
if (replacement == null) {
return chain.proceed();
}
Object[] args = chain.getArgs().toArray();
args[0] = replacement;
return chain.proceed(args);
});
XposedHookSupport.hookAllMethods(framework, dozeMachineClass, "transitionTo", chain -> {
Object state = chain.getArgs().isEmpty() ? null : chain.getArg(0);
if (!(state instanceof Enum<?> target) || !shouldForceAodForCall()) {
return chain.proceed();
}
Enum<?> replacement = overrideDozeState(target);
if (replacement == null) {
return chain.proceed();
}
Object[] args = chain.getArgs().toArray();
args[0] = replacement;
return chain.proceed(args);
});
}
private void hookPluginPhoneStateReadOverride(ClassLoader classLoader) {
Class<?> configClass = ReflectionSupport.findClassIfExists(
"com.android.systemui.plugins.aod.PluginAODSystemUIConfiguration",
classLoader
);
if (configClass == null) {
return;
}
XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), configClass, "get", chain -> {
Object result = chain.proceed();
if (!shouldForceAodForCall() || chain.getArgs().size() < 2) {
return result;
}
Object keyObj = chain.getArg(0);
if (!(keyObj instanceof Integer key) || key != PLUGIN_KEY_PHONE_STATE) {
return result;
}
int phoneState = result instanceof Integer integer
? integer
: (chain.getArg(1) instanceof Integer fallback ? fallback : TelephonyManager.CALL_STATE_IDLE);
if (phoneState == TelephonyManager.CALL_STATE_IDLE) {
return result;
}
return TelephonyManager.CALL_STATE_IDLE;
});
}
private void hookBooleanVoidMethod(ClassLoader classLoader, String className, String methodName) {
Class<?> targetClass = ReflectionSupport.findClassIfExists(className, classLoader);
if (targetClass == null) {
return;
}
XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), targetClass, methodName, chain -> {
if (chain.getArgs().isEmpty() || !(chain.getArg(0) instanceof Boolean booleanArg)) {
return chain.proceed();
}
if (!booleanArg || !shouldForceAodForCall()) {
return chain.proceed();
}
Object[] args = chain.getArgs().toArray();
args[0] = false;
return chain.proceed(args);
});
}
private Enum<?> overrideDozeState(Enum<?> requested) {
String stateName = requested.name();
if (!"DOZE".equals(stateName) && !"FINISH".equals(stateName)) {
return null;
}
try {
@SuppressWarnings({"rawtypes", "unchecked"})
Enum<?> dozeAod = Enum.valueOf((Class) requested.getDeclaringClass(), "DOZE_AOD");
return dozeAod;
} catch (Throwable ignored) {
return null;
}
}
private boolean shouldForceAodForCall() {
Context context = getSystemContext();
if (context == null) {
return false;
}
if (!RuntimeSettingsCache.get(context).keepAodVisibleDuringCall) {
return false;
}
return isInCallLikeState(context);
}
private boolean isInCallLikeState(Context context) {
try {
TelecomManager telecomManager = context.getSystemService(TelecomManager.class);
if (telecomManager != null && telecomManager.isInCall()) {
return true;
}
} catch (Throwable ignored) {
}
try {
TelephonyManager telephonyManager = context.getSystemService(TelephonyManager.class);
if (telephonyManager != null
&& telephonyManager.getCallState() != TelephonyManager.CALL_STATE_IDLE) {
return true;
}
} catch (Throwable ignored) {
}
try {
AudioManager audioManager = context.getSystemService(AudioManager.class);
if (audioManager == null) {
return false;
}
int mode = audioManager.getMode();
return mode == AudioManager.MODE_IN_CALL || mode == AudioManager.MODE_IN_COMMUNICATION;
} catch (Throwable ignored) {
return false;
}
}
private Context getSystemContext() {
try {
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Object app = ReflectionSupport.invokeStaticMethod(
activityThreadClass,
"currentApplication",
new Class<?>[0]
);
return app instanceof Context context ? context : null;
} catch (Throwable ignored) {
return null;
}
}
}
@@ -0,0 +1,123 @@
package se.ajpanton.statusbartweak.runtime.features.misc;
import android.content.Context;
import android.content.res.ColorStateList;
import java.text.NumberFormat;
import io.github.libxposed.api.XposedInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport;
import se.ajpanton.statusbartweak.runtime.hooks.XposedHookSupport;
import se.ajpanton.statusbartweak.runtime.settings.RuntimeSettingsCache;
final class BatteryIndicationController {
private final RuntimeContext runtimeContext;
private Object batteryEvent;
private Object batteryRestingEvent;
BatteryIndicationController(RuntimeContext runtimeContext) {
this.runtimeContext = runtimeContext;
}
void install(ClassLoader classLoader) {
initEventTypes(classLoader);
hookAddInitialIndication(classLoader);
}
private void initEventTypes(ClassLoader classLoader) {
Class<?> eventTypeClass = ReflectionSupport.findClassIfExists(
"com.android.systemui.statusbar.IndicationEventType",
classLoader
);
if (eventTypeClass == null) {
return;
}
batteryEvent = ReflectionSupport.getStaticFieldValue(eventTypeClass, "BATTERY");
batteryRestingEvent = ReflectionSupport.getStaticFieldValue(eventTypeClass, "BATTERY_RESTING");
}
private void hookAddInitialIndication(ClassLoader classLoader) {
Class<?> controllerClass = ReflectionSupport.findClassIfExists(
"com.android.systemui.statusbar.KeyguardSecIndicationController",
classLoader
);
if (controllerClass == null) {
return;
}
XposedHookSupport.hookAllMethods(
runtimeContext.getFramework(),
controllerClass,
"addInitialIndication",
chain -> {
Object result = chain.proceed();
Object controller = chain.getThisObject();
BatteryStatusAccess.BatteryState batteryState = resolveVisibleBatteryState(controller);
CharSequence text = buildBatteryPercentageText(batteryState);
if (text == null || text.length() == 0) {
return result;
}
ColorStateList color = null;
Object colorField = ReflectionSupport.getFieldValue(controller, "mInitialTextColorState");
if (colorField instanceof ColorStateList colorStateList) {
color = colorStateList;
}
ReflectionSupport.invokeMethod(
controller,
"addIndicationTimeout",
new Class<?>[] {
batteryEvent != null ? batteryEvent.getClass() : Object.class,
CharSequence.class,
ColorStateList.class,
boolean.class
},
batteryEvent,
text,
color,
false
);
ReflectionSupport.invokeMethod(
controller,
"addIndication",
new Class<?>[] {
batteryRestingEvent != null ? batteryRestingEvent.getClass() : Object.class,
CharSequence.class
},
batteryRestingEvent,
text
);
return result;
}
);
}
private BatteryStatusAccess.BatteryState resolveVisibleBatteryState(Object controller) {
if (controller == null) {
return null;
}
Context context = BatteryStatusAccess.getContextField(controller, "mContext");
if (context == null) {
return null;
}
if (!RuntimeSettingsCache.get(context).batteryLockscreenUnpluggedPercent) {
return null;
}
if (BatteryStatusAccess.getBooleanField(controller, "mDozing", false)) {
return null;
}
BatteryStatusAccess.BatteryState state = BatteryStatusAccess.getCurrentBatteryState(context);
if (state == null || state.plugged() || state.charged()) {
return null;
}
return state;
}
private CharSequence buildBatteryPercentageText(BatteryStatusAccess.BatteryState state) {
if (state == null || state.level() < 0) {
return null;
}
NumberFormat format = NumberFormat.getPercentInstance();
return format.format(state.level() / 100.0d);
}
}
@@ -0,0 +1,60 @@
package se.ajpanton.statusbartweak.runtime.features.misc;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.BatteryManager;
import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport;
final class BatteryStatusAccess {
private BatteryStatusAccess() {
}
static BatteryState getCurrentBatteryState(Context context) {
if (context == null) {
return null;
}
Intent batteryIntent = context.registerReceiver(
null,
new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
if (batteryIntent == null) {
return null;
}
int level = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
int scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, 100);
if (level < 0 || scale <= 0) {
return null;
}
int percent = Math.round((level * 100f) / scale);
int status = batteryIntent.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
boolean plugged = batteryIntent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
|| status == BatteryManager.BATTERY_STATUS_CHARGING
|| status == BatteryManager.BATTERY_STATUS_FULL;
boolean charged = status == BatteryManager.BATTERY_STATUS_FULL || percent >= 100;
return new BatteryState(percent, plugged, charged);
}
static Context getContextField(Object target, String fieldName) {
Object value = ReflectionSupport.getFieldValue(target, fieldName);
return value instanceof Context context ? context : null;
}
static int getIntField(Object target, String fieldName, int fallback) {
Object value = ReflectionSupport.getFieldValue(target, fieldName);
return value instanceof Integer integer ? integer : fallback;
}
static boolean getBooleanField(Object target, String fieldName, boolean fallback) {
Object value = ReflectionSupport.getFieldValue(target, fieldName);
return value instanceof Boolean bool ? bool : fallback;
}
static Object getObjectField(Object target, String fieldName) {
return ReflectionSupport.getFieldValue(target, fieldName);
}
record BatteryState(int level, boolean plugged, boolean charged) {
}
}
@@ -0,0 +1,47 @@
package se.ajpanton.statusbartweak.runtime.features.misc;
import io.github.libxposed.api.XposedModuleInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.features.RuntimeFeature;
/**
* Reserved home for Misc runtime behaviours that stay outside the icon
* placement engine.
*/
public final class MiscFeature implements RuntimeFeature {
private AodCallVisibilityController aodCallVisibilityController;
private BatteryIndicationController batteryIndicationController;
private AodBottomBatteryFallbackController aodBottomBatteryFallbackController;
@Override
public String getName() {
return "misc";
}
@Override
public void onPackageReady(
RuntimeContext context,
XposedModuleInterface.PackageReadyParam param
) {
if (param == null || !"com.android.systemui".equals(param.getPackageName())) {
return;
}
installControllers(context, param.getClassLoader());
}
private void installControllers(RuntimeContext context, ClassLoader classLoader) {
if (aodCallVisibilityController == null) {
aodCallVisibilityController = new AodCallVisibilityController(context);
}
if (batteryIndicationController == null) {
batteryIndicationController = new BatteryIndicationController(context);
}
if (aodBottomBatteryFallbackController == null) {
aodBottomBatteryFallbackController = new AodBottomBatteryFallbackController(context);
}
aodCallVisibilityController.install(classLoader);
batteryIndicationController.install(classLoader);
aodBottomBatteryFallbackController.install(classLoader);
}
}
@@ -0,0 +1,273 @@
package se.ajpanton.statusbartweak.runtime.features.notifications;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.res.Resources;
import android.os.Build;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import java.util.Collections;
import java.util.Set;
import java.util.WeakHashMap;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport;
import se.ajpanton.statusbartweak.runtime.hooks.XposedHookSupport;
import se.ajpanton.statusbartweak.runtime.settings.RuntimeSettingsCache;
import se.ajpanton.statusbartweak.shell.settings.SbtDefaults;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
final class StockUnlockedNotificationIconsController {
private static final String CONTAINER_CLASS_NAME =
"com.android.systemui.statusbar.phone.NotificationIconContainer";
private static final int INFINITE_ICON_LIMIT = 1000;
private final RuntimeContext runtimeContext;
private final Set<View> trackedContainers =
Collections.newSetFromMap(new WeakHashMap<>());
private final WeakHashMap<View, Integer> stockMaxStaticIcons = new WeakHashMap<>();
private boolean hooksInstalled;
private boolean receiverRegistered;
StockUnlockedNotificationIconsController(RuntimeContext runtimeContext) {
this.runtimeContext = runtimeContext;
}
void install(ClassLoader classLoader) {
if (hooksInstalled || classLoader == null) {
return;
}
Class<?> containerClass = ReflectionSupport.requireClass(CONTAINER_CLASS_NAME, classLoader);
XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), containerClass, "onAttachedToWindow", chain -> {
Object result = chain.proceed();
if (chain.getThisObject() instanceof View view && shouldManageContainer(view)) {
trackContainer(view);
view.post(() -> refreshContainer(view));
}
return result;
});
XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), containerClass, "calculateIconXTranslations", chain -> {
if (chain.getThisObject() instanceof View view && shouldManageContainer(view)) {
trackContainer(view);
applyLimitIfNeeded(view);
}
return chain.proceed();
});
XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), containerClass, "onLayout", chain -> {
Object result = chain.proceed();
if (chain.getThisObject() instanceof View view && shouldManageContainer(view)) {
trackContainer(view);
if (isFeatureEnabled(view.getContext())) {
enforceParentWidth(view);
}
}
return result;
});
XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), containerClass, "shouldForceOverflow", chain -> {
if (!(chain.getThisObject() instanceof View view)) {
return chain.proceed();
}
if (!shouldManageContainer(view)) {
return chain.proceed();
}
trackContainer(view);
if (chain.getArgs().size() < 4 || !isFeatureEnabled(view.getContext())) {
return chain.proceed();
}
int override = resolveUnlockedLimit(view.getContext());
if (override <= 0) {
return chain.proceed();
}
applyOverrideToContainer(view, override);
int index = chain.getArg(0) instanceof Integer value ? value : -1;
int speedBumpIndex = chain.getArg(1) instanceof Integer value ? value : -1;
float appear = chain.getArg(2) instanceof Float value ? value : 0f;
return (speedBumpIndex != -1 && index >= speedBumpIndex && appear > 0f)
|| index >= override;
});
hooksInstalled = true;
}
private void trackContainer(View view) {
trackedContainers.add(view);
captureStockMaxStaticIcons(view);
registerReceiverIfNeeded(view.getContext());
}
private void captureStockMaxStaticIcons(View view) {
if (stockMaxStaticIcons.containsKey(view)) {
return;
}
Object value = ReflectionSupport.getFieldValue(view, "mMaxStaticIcons");
if (value instanceof Integer integer) {
stockMaxStaticIcons.put(view, integer);
}
}
private void registerReceiverIfNeeded(Context context) {
if (receiverRegistered || context == null) {
return;
}
Context appContext = context.getApplicationContext();
if (appContext == null) {
appContext = context;
}
BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
refreshTrackedContainers();
}
};
IntentFilter filter = new IntentFilter(SbtSettings.ACTION_SETTINGS_CHANGED);
filter.addAction(Intent.ACTION_USER_UNLOCKED);
if (Build.VERSION.SDK_INT >= 33) {
appContext.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED);
} else {
appContext.registerReceiver(receiver, filter);
}
receiverRegistered = true;
}
private void refreshTrackedContainers() {
for (View view : trackedContainers) {
if (shouldManageContainer(view)) {
view.post(() -> refreshContainer(view));
}
}
}
private void refreshContainer(View view) {
applyLimitIfNeeded(view);
forceLayout(view);
tryRecalculateIcons(view);
}
private void applyLimitIfNeeded(View view) {
captureStockMaxStaticIcons(view);
if (!isFeatureEnabled(view.getContext())) {
restoreStockLimit(view);
return;
}
int override = resolveUnlockedLimit(view.getContext());
if (override <= 0) {
restoreStockLimit(view);
return;
}
applyOverrideToContainer(view, override);
enforceParentWidth(view);
}
private void restoreStockLimit(View view) {
Integer stock = stockMaxStaticIcons.get(view);
if (stock != null) {
ReflectionSupport.setFieldValue(view, "mMaxStaticIcons", stock);
}
}
private void applyOverrideToContainer(View view, int override) {
ReflectionSupport.setFieldValue(view, "mMaxStaticIcons", override);
}
private void enforceParentWidth(View view) {
ViewParent parent = view.getParent();
int depth = 0;
while (parent instanceof View && depth < 3) {
View parentView = (View) parent;
String name = parentView.getClass().getName();
if (name.contains("AlphaOptimizedLinearLayout")
|| name.contains("AlphaOptimizedFrameLayout")) {
ViewGroup.LayoutParams lp = parentView.getLayoutParams();
if (lp != null && (lp.width == ViewGroup.LayoutParams.WRAP_CONTENT || lp.width == 0)) {
lp.width = ViewGroup.LayoutParams.MATCH_PARENT;
parentView.setLayoutParams(lp);
parentView.requestLayout();
}
}
parent = parentView.getParent();
depth++;
}
}
private void forceLayout(View view) {
view.forceLayout();
view.requestLayout();
view.invalidate();
ViewParent parent = view.getParent();
if (parent instanceof View parentView) {
parentView.requestLayout();
parentView.invalidate();
}
}
private void tryRecalculateIcons(View view) {
ReflectionSupport.invokeMethod(view, "calculateIconXTranslations", new Class<?>[0]);
ReflectionSupport.invokeMethod(view, "applyIconStates", new Class<?>[0]);
ReflectionSupport.invokeMethod(view, "onNotificationInfoUpdated", new Class<?>[0]);
}
private boolean isFeatureEnabled(Context context) {
SbtSettings settings = RuntimeSettingsCache.get(context);
return settings != null && !settings.clockEnabled;
}
private int resolveUnlockedLimit(Context context) {
SbtSettings settings = RuntimeSettingsCache.get(context);
if (settings == null || settings.clockEnabled) {
return -1;
}
int value = settings.unlockedMaxIconsPerRow;
if (value == Integer.MAX_VALUE || value == 0) {
return INFINITE_ICON_LIMIT;
}
if (value < SbtDefaults.UNLOCKED_MAX_ICONS_PER_ROW_MIN) {
return SbtDefaults.UNLOCKED_MAX_ICONS_PER_ROW_DEFAULT;
}
return value;
}
private boolean isKeyguardContainer(Object container) {
return ReflectionSupport.getBooleanField(container, "mOnKeyguardStatusBar", false);
}
private boolean shouldManageContainer(View view) {
if (isKeyguardContainer(view)) {
return false;
}
ViewParent parent = view.getParent();
boolean inUnlockedStatusBarIconArea = false;
int depth = 0;
while (parent instanceof View parentView && depth < 8) {
String idName = resolveIdName(parentView);
if (idName.equals("notification_icon_area")
|| idName.equals("notification_icon_area_inner")) {
inUnlockedStatusBarIconArea = true;
}
String className = parentView.getClass().getName();
if (className.contains("KeyguardRootView")
|| className.contains("NotificationShelf")
|| className.contains("NotificationStackScrollLayout")) {
return false;
}
parent = parentView.getParent();
depth++;
}
return inUnlockedStatusBarIconArea;
}
private 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 "";
}
}
}
@@ -0,0 +1,34 @@
package se.ajpanton.statusbartweak.runtime.features.notifications;
import io.github.libxposed.api.XposedModuleInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.features.RuntimeFeature;
/**
* Keeps the original simple unlocked stock-icon count override available while
* the rewritten custom layout engine is still disabled.
*/
public final class StockUnlockedNotificationIconsFeature implements RuntimeFeature {
private StockUnlockedNotificationIconsController controller;
@Override
public String getName() {
return "stock-unlocked-notification-icons";
}
@Override
public void onPackageReady(RuntimeContext context, XposedModuleInterface.PackageReadyParam param) {
if (param == null || !"com.android.systemui".equals(param.getPackageName())) {
return;
}
installController(context, param.getClassLoader());
}
private void installController(RuntimeContext context, ClassLoader classLoader) {
if (controller == null) {
controller = new StockUnlockedNotificationIconsController(context);
}
controller.install(classLoader);
}
}
@@ -0,0 +1,43 @@
package se.ajpanton.statusbartweak.runtime.hooks;
import java.util.HashMap;
import java.util.Map;
import java.util.WeakHashMap;
/**
* Replacement for legacy Xposed additional-instance-field helpers.
*/
public final class InstanceStateStore {
private final WeakHashMap<Object, Map<String, Object>> state = new WeakHashMap<>();
public synchronized Object get(Object target, String key) {
if (target == null || key == null) {
return null;
}
Map<String, Object> fields = state.get(target);
return fields != null ? fields.get(key) : null;
}
public synchronized void put(Object target, String key, Object value) {
if (target == null || key == null) {
return;
}
Map<String, Object> fields = state.computeIfAbsent(target, ignored -> new HashMap<>());
fields.put(key, value);
}
public synchronized void remove(Object target, String key) {
if (target == null || key == null) {
return;
}
Map<String, Object> fields = state.get(target);
if (fields == null) {
return;
}
fields.remove(key);
if (fields.isEmpty()) {
state.remove(target);
}
}
}
@@ -0,0 +1,181 @@
package se.ajpanton.statusbartweak.runtime.hooks;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
/**
* Small reflection helpers used by rebuilt runtime slices.
*/
public final class ReflectionSupport {
private ReflectionSupport() {
}
public static Class<?> findClassIfExists(String className, ClassLoader classLoader) {
if (className == null || className.isEmpty()) {
return null;
}
try {
return Class.forName(className, false, classLoader);
} catch (Throwable ignored) {
if (classLoader == null) {
try {
return Class.forName(className);
} catch (Throwable ignoredAgain) {
return null;
}
}
return null;
}
}
public static Class<?> requireClass(String className, ClassLoader classLoader) {
Class<?> type = findClassIfExists(className, classLoader);
if (type == null) {
throw new IllegalStateException("Required class not found: " + className);
}
return type;
}
public static Object getFieldValue(Object target, String fieldName) {
if (target == null || fieldName == null || fieldName.isEmpty()) {
return null;
}
Field field = findField(target.getClass(), fieldName);
if (field == null) {
return null;
}
try {
return field.get(target);
} catch (Throwable ignored) {
return null;
}
}
public static Object requireFieldValue(Object target, String fieldName) {
if (target == null) {
throw new IllegalArgumentException("Target is null for field: " + fieldName);
}
Field field = requireField(target.getClass(), fieldName);
try {
return field.get(target);
} catch (IllegalAccessException exception) {
throw new IllegalStateException(
"Required field not readable: " + target.getClass().getName() + "#" + fieldName,
exception);
}
}
public static Object getStaticFieldValue(Class<?> type, String fieldName) {
if (type == null || fieldName == null || fieldName.isEmpty()) {
return null;
}
Field field = findField(type, fieldName);
if (field == null) {
return null;
}
try {
return field.get(null);
} catch (Throwable ignored) {
return null;
}
}
public static boolean getBooleanField(Object target, String fieldName, boolean fallback) {
Object value = getFieldValue(target, fieldName);
return value instanceof Boolean ? (Boolean) value : fallback;
}
public static void setFieldValue(Object target, String fieldName, Object value) {
if (target == null || fieldName == null || fieldName.isEmpty()) {
return;
}
Field field = findField(target.getClass(), fieldName);
if (field == null) {
return;
}
try {
field.set(target, value);
} catch (Throwable ignored) {
// Optional reflection path; absence/failure means no override.
}
}
public static Object invokeMethod(
Object target,
String methodName,
Class<?>[] parameterTypes,
Object... args
) {
if (target == null || methodName == null || methodName.isEmpty()) {
return null;
}
Method method = findMethod(target.getClass(), methodName, parameterTypes);
if (method == null) {
return null;
}
try {
return method.invoke(target, args);
} catch (Throwable ignored) {
return null;
}
}
public static Object invokeStaticMethod(
Class<?> type,
String methodName,
Class<?>[] parameterTypes,
Object... args
) {
if (type == null || methodName == null || methodName.isEmpty()) {
return null;
}
Method method = findMethod(type, methodName, parameterTypes);
if (method == null) {
return null;
}
try {
return method.invoke(null, args);
} catch (Throwable ignored) {
return null;
}
}
private static Field requireField(Class<?> type, String fieldName) {
if (fieldName == null || fieldName.isEmpty()) {
throw new IllegalArgumentException("Field name is empty");
}
Field field = findField(type, fieldName);
if (field == null) {
throw new IllegalStateException("Required field not found: " + type.getName() + "#" + fieldName);
}
return field;
}
private static Field findField(Class<?> type, String fieldName) {
Class<?> current = type;
while (current != null) {
try {
Field field = current.getDeclaredField(fieldName);
field.setAccessible(true);
return field;
} catch (NoSuchFieldException ignored) {
current = current.getSuperclass();
}
}
return null;
}
private static Method findMethod(Class<?> type, String methodName, Class<?>[] parameterTypes) {
Class<?> current = type;
while (current != null) {
try {
Method method = current.getDeclaredMethod(methodName, parameterTypes);
method.setAccessible(true);
return method;
} catch (NoSuchMethodException ignored) {
current = current.getSuperclass();
}
}
return null;
}
}
@@ -0,0 +1,86 @@
package se.ajpanton.statusbartweak.runtime.hooks;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import io.github.libxposed.api.XposedInterface;
/**
* Narrow libxposed hook helpers.
*
* The rebuilt runtime should depend on a small adapter layer instead of
* spreading raw framework hook calls across every feature port.
*/
public final class XposedHookSupport {
private XposedHookSupport() {
}
public static List<XposedInterface.HookHandle> hookAllMethods(
XposedInterface framework,
Class<?> type,
String methodName,
XposedInterface.Hooker hooker
) {
Objects.requireNonNull(framework, "framework");
Objects.requireNonNull(type, "type");
Objects.requireNonNull(methodName, "methodName");
Objects.requireNonNull(hooker, "hooker");
List<XposedInterface.HookHandle> handles = new ArrayList<>();
for (Method method : type.getDeclaredMethods()) {
if (!methodName.equals(method.getName())) {
continue;
}
method.setAccessible(true);
handles.add(framework.hook(method).intercept(hooker));
}
if (handles.isEmpty()) {
throw new IllegalStateException(
"No methods matched hook target: " + type.getName() + "#" + methodName);
}
return handles;
}
public static List<XposedInterface.HookHandle> hookAllMethodsIfExists(
XposedInterface framework,
Class<?> type,
String methodName,
XposedInterface.Hooker hooker
) {
Objects.requireNonNull(framework, "framework");
Objects.requireNonNull(type, "type");
Objects.requireNonNull(methodName, "methodName");
Objects.requireNonNull(hooker, "hooker");
List<XposedInterface.HookHandle> handles = new ArrayList<>();
for (Method method : type.getDeclaredMethods()) {
if (!methodName.equals(method.getName())) {
continue;
}
method.setAccessible(true);
handles.add(framework.hook(method).intercept(hooker));
}
return handles;
}
public static List<XposedInterface.HookHandle> hookAllConstructors(
XposedInterface framework,
Class<?> type,
XposedInterface.Hooker hooker
) {
Objects.requireNonNull(framework, "framework");
Objects.requireNonNull(type, "type");
Objects.requireNonNull(hooker, "hooker");
List<XposedInterface.HookHandle> handles = new ArrayList<>();
for (Constructor<?> constructor : type.getDeclaredConstructors()) {
constructor.setAccessible(true);
handles.add(framework.hook(constructor).intercept(hooker));
}
if (handles.isEmpty()) {
throw new IllegalStateException("No constructors matched hook target: " + type.getName());
}
return handles;
}
}
@@ -0,0 +1,903 @@
package se.ajpanton.statusbartweak.runtime.layout;
import android.app.KeyguardManager;
import android.content.Context;
import android.content.res.Resources;
import android.os.PowerManager;
import android.view.View;
import android.view.ViewGroup;
import java.util.ArrayDeque;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.function.Predicate;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
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.render.DebugBoundsOverlayController;
import se.ajpanton.statusbartweak.runtime.render.OwnedIconHostManager;
import se.ajpanton.statusbartweak.runtime.render.UnlockedIconSnapshotRenderController;
import se.ajpanton.statusbartweak.runtime.render.UnlockedIconSnapshotRenderController.RenderResult;
import se.ajpanton.statusbartweak.runtime.settings.RuntimeSettingsCache;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
final class StockLayoutCanvasController {
private static final String CLASS_PLUGIN_AOD_MANAGER =
"com.android.systemui.doze.PluginAODManager";
private static final String CLASS_AOD_DIRECT_CONTROLLER = "aod.QI";
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 Set<String> TARGET_ID_NAMES = Set.of(
"left_clock_container",
"notification_icon_area",
"notification_icon_area_inner",
"system_icon_area",
"statusIcons",
"battery",
"notification_icononly_container",
"notification_icononly_area",
"keyguard_icononly_container_view",
"common_notification_widget"
);
private static final Set<String> TARGET_CLASS_SUBSTRINGS = Set.of(
"QSClockIndicatorView",
"SecShelfNotificationIconContainer"
);
private final RuntimeContext runtimeContext;
private final Map<View, HiddenViewState> hiddenViewStates = new WeakHashMap<>();
private final Map<View, RootSize> rootSizes = new WeakHashMap<>();
private final Map<View, Integer> debugOverlaySignatures = new WeakHashMap<>();
private final Set<View> confirmedAodPluginViews =
Collections.newSetFromMap(new WeakHashMap<>());
private final Set<View> layoutOffCleanedRoots =
Collections.newSetFromMap(new WeakHashMap<>());
private final Set<View> trackedStatusChipSources =
Collections.newSetFromMap(new WeakHashMap<>());
private final Set<View> trackedUnlockedStockSurfaces =
Collections.newSetFromMap(new WeakHashMap<>());
private final Set<View> trackedLockedStatusBarIconSurfaces =
Collections.newSetFromMap(new WeakHashMap<>());
private final Set<ClassLoader> hookedPluginClassLoaders =
Collections.newSetFromMap(new IdentityHashMap<>());
private final Map<View, UnlockedHosts> unlockedHostsByRoot = new WeakHashMap<>();
private final OwnedIconHostManager ownedIconHostManager = new OwnedIconHostManager();
private final DebugBoundsOverlayController debugBoundsOverlayController =
new DebugBoundsOverlayController();
private final UnlockedIconSnapshotRenderController unlockedIconRenderController;
private boolean hooksInstalled;
StockLayoutCanvasController(RuntimeContext runtimeContext) {
this.runtimeContext = runtimeContext;
unlockedIconRenderController = new UnlockedIconSnapshotRenderController();
}
void install(ClassLoader classLoader) {
if (hooksInstalled || classLoader == null) {
return;
}
installPluginManagerHooks(classLoader);
installViewRootHook(classLoader);
hooksInstalled = true;
}
private void installPluginManagerHooks(ClassLoader classLoader) {
Class<?> managerClass = ReflectionSupport.findClassIfExists(CLASS_PLUGIN_AOD_MANAGER, classLoader);
if (managerClass == null) {
return;
}
XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), managerClass, "setAODPlugin", chain -> {
Object result = chain.proceed();
Object plugin = firstArg(chain.getArgs());
if (plugin == null) {
return result;
}
ClassLoader pluginClassLoader = plugin.getClass().getClassLoader();
if (pluginClassLoader != null && hookedPluginClassLoaders.add(pluginClassLoader)) {
installPluginClassLoaderHooks(pluginClassLoader);
}
return result;
});
}
private void installPluginClassLoaderHooks(ClassLoader pluginClassLoader) {
Class<?> qiClass = ReflectionSupport.findClassIfExists(CLASS_AOD_DIRECT_CONTROLLER, pluginClassLoader);
if (qiClass != null) {
XposedHookSupport.hookAllMethodsIfExists(runtimeContext.getFramework(), qiClass, "b", chain -> {
Object result = chain.proceed();
enforceDirectAodControllerPolicy(chain.getThisObject());
return result;
});
XposedHookSupport.hookAllMethodsIfExists(runtimeContext.getFramework(), qiClass, "e", chain -> {
Object result = chain.proceed();
enforceDirectAodControllerPolicy(chain.getThisObject());
return result;
});
}
Class<?> iconsControllerClass =
ReflectionSupport.findClassIfExists(CLASS_AOD_ICONS_CONTROLLER, pluginClassLoader);
if (iconsControllerClass != null) {
XposedHookSupport.hookAllMethodsIfExists(runtimeContext.getFramework(), iconsControllerClass, "f", chain -> {
Object result = chain.proceed();
enforceIconsOnlyControllerPolicy(firstArg(chain.getArgs()));
return result;
});
}
Class<?> iconsOnlyClass =
ReflectionSupport.findClassIfExists(CLASS_AOD_ICONS_ONLY_CONTAINER, pluginClassLoader);
if (iconsOnlyClass != null) {
XposedHookSupport.hookAllMethodsIfExists(runtimeContext.getFramework(), iconsOnlyClass, "onLayout", chain -> {
Object result = chain.proceed();
if (chain.getThisObject() instanceof View view) {
applyConfirmedAodPluginPolicy(view);
}
return result;
});
}
}
private void installViewRootHook(ClassLoader classLoader) {
Class<?> viewRootClass = ReflectionSupport.requireClass("android.view.ViewRootImpl", classLoader);
XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), viewRootClass, "performDraw", chain -> {
Object rootObj = ReflectionSupport.requireFieldValue(chain.getThisObject(), "mView");
View root = rootObj instanceof View candidate ? candidate : null;
if (root != null) {
applyBeforeDraw(root);
}
return chain.proceed();
});
XposedHookSupport.hookAllMethods(runtimeContext.getFramework(), viewRootClass, "performTraversals", chain -> {
Object result = chain.proceed();
Object rootObj = ReflectionSupport.requireFieldValue(chain.getThisObject(), "mView");
if (rootObj instanceof View root) {
applyToRoot(root);
}
return result;
});
}
private void applyBeforeDraw(View root) {
if (root == null) {
return;
}
SbtSettings settings = RuntimeSettingsCache.get(root.getContext());
if (!settings.clockEnabled) {
return;
}
SceneKey activeScene = runtimeContext.getModeStateRepository().getActiveScene();
if (activeScene != null && activeScene.isAod()) {
hideConfirmedAodPluginViews();
}
if (activeScene != null && !activeScene.isUnlocked()) {
hideTrackedLockedStatusBarIconSurfaces(null);
}
if (!isUnlockedStatusBarRoot(root)) {
return;
}
if (activeScene != null && activeScene.isUnlocked()) {
if (isKnownRootSizeChanged(root)) {
ownedIconHostManager.setUnlockedHostsVisible(root, false);
removeDebugOverlay(root);
}
hideTrackedUnlockedStockSurfaces(root);
}
}
private void applyToRoot(View root) {
if (root == null) {
return;
}
Context context = root.getContext();
SbtSettings settings = RuntimeSettingsCache.get(context);
boolean layoutEnabled = settings.clockEnabled;
boolean statusBarRoot = isUnlockedStatusBarRoot(root);
runtimeContext.getModeStateRepository().setLayoutEnabled(layoutEnabled);
if (!layoutEnabled) {
if (statusBarRoot) {
applyDebugOverlayIfNeeded(root, false, settings, false);
} else {
removeDebugOverlay(root);
}
cleanupLayoutOffRoot(root);
return;
}
layoutOffCleanedRoots.remove(root);
updateActiveScene(root, context);
SceneKey activeScene = runtimeContext.getModeStateRepository().getActiveScene();
boolean unlockedScene = activeScene != null && activeScene.isUnlocked();
if (!statusBarRoot) {
if (!unlockedScene && canContainLockedStatusBarIconSurfaces(root)) {
hideOrDiscoverLockedStatusBarIconSurfaces(root);
}
removeDebugOverlay(root);
return;
}
UnlockedHosts unlockedHosts = updateUnlockedOwnedHosts(root, activeScene);
if (!unlockedScene) {
trackedUnlockedStockSurfaces.clear();
restoreTransientUnlockedSources();
hideOrDiscoverLockedStatusBarIconSurfaces(root);
removeDebugOverlay(root);
return;
}
RenderResult renderResult = new RenderResult(false, false);
if (unlockedHosts != null) {
markRootSizeBeforeDraw(root);
renderResult = unlockedIconRenderController.renderUnlocked(
root,
unlockedHosts.clockHost,
unlockedHosts.chipHost,
unlockedHosts.statusHost,
unlockedHosts.notificationHost,
activeScene);
ownedIconHostManager.setUnlockedHostsVisible(root, true);
}
boolean scanTree = renderResult.stockSourcesChanged() || !hasTrackedUnlockedStockSurfaces(root);
applyDebugOverlayIfNeeded(root, true, settings, renderResult.layoutChanged());
hideTrackedStatusChipSources(root);
if (renderResult.chipSourcesChanged()
&& !scanTree
&& !hasTrackedStatusChipSources(root)) {
trackAndHideStatusChipSources(root);
}
if (!scanTree) {
return;
}
walkTree(root, view -> {
boolean statusChipSource = isStatusChipSource(view);
if (statusChipSource) {
trackedStatusChipSources.add(view);
}
boolean shouldHide = statusChipSource || shouldHideNonChipView(view);
if (shouldHide) {
trackedUnlockedStockSurfaces.add(view);
hideView(view, true);
} else {
trackedUnlockedStockSurfaces.remove(view);
restoreView(view);
}
});
}
private void applyDebugOverlay(
View root,
boolean layoutEnabled,
SbtSettings settings
) {
if (!debugBoundsOverlayController.hasEnabledVisualizer(layoutEnabled, settings)) {
debugBoundsOverlayController.remove(root);
return;
}
debugBoundsOverlayController.apply(root, layoutEnabled, settings);
}
private void applyDebugOverlayIfNeeded(
View root,
boolean layoutEnabled,
SbtSettings settings,
boolean force
) {
int signature = debugOverlaySignature(root, layoutEnabled, settings);
Integer previous = debugOverlaySignatures.get(root);
if (!force && previous != null && previous == signature) {
return;
}
debugOverlaySignatures.put(root, signature);
applyDebugOverlay(root, layoutEnabled, settings);
}
private int debugOverlaySignature(
View root,
boolean layoutEnabled,
SbtSettings settings
) {
int result = 17;
result = 31 * result + (layoutEnabled ? 1 : 0);
result = 31 * result + (root != null ? root.getWidth() : 0);
result = 31 * result + (root != null ? root.getHeight() : 0);
if (settings != null) {
result = 31 * result + (settings.debugVisualizeCameraCutout ? 1 : 0);
result = 31 * result + (settings.debugVisualizeNotificationContainer ? 1 : 0);
result = 31 * result + (settings.debugVisualizeStatusContainer ? 1 : 0);
result = 31 * result + (settings.debugVisualizeClockContainer ? 1 : 0);
result = 31 * result + (settings.debugVisualizeChipContainer ? 1 : 0);
}
return result;
}
private void removeDebugOverlay(View root) {
debugOverlaySignatures.remove(root);
debugBoundsOverlayController.remove(root);
}
private void restoreTransientUnlockedSources() {
if (hiddenViewStates.isEmpty()) {
return;
}
ArrayDeque<View> views = new ArrayDeque<>(hiddenViewStates.keySet());
while (!views.isEmpty()) {
View view = views.removeFirst();
if (confirmedAodPluginViews.contains(view)
|| trackedLockedStatusBarIconSurfaces.contains(view)) {
continue;
}
restoreView(view);
}
}
private void hideTrackedStatusChipSources(View root) {
if (trackedStatusChipSources.isEmpty()) {
return;
}
ArrayDeque<View> staleViews = new ArrayDeque<>();
for (View view : trackedStatusChipSources) {
if (view == null
|| !view.isAttachedToWindow()
|| !isDescendantOf(view, root)
|| ownedIconHostManager.isOwnedHost(view)) {
staleViews.add(view);
continue;
}
hideView(view, true);
}
while (!staleViews.isEmpty()) {
trackedStatusChipSources.remove(staleViews.removeFirst());
}
}
private void trackAndHideStatusChipSources(View root) {
walkTree(root, view -> {
if (isStatusChipSource(view)) {
trackedStatusChipSources.add(view);
hideView(view, true);
}
});
}
private void hideTrackedUnlockedStockSurfaces(View root) {
if (trackedUnlockedStockSurfaces.isEmpty()) {
return;
}
ArrayDeque<View> staleViews = new ArrayDeque<>();
for (View view : trackedUnlockedStockSurfaces) {
if (view == null
|| !view.isAttachedToWindow()
|| !isDescendantOf(view, root)
|| ownedIconHostManager.isOwnedHost(view)) {
staleViews.add(view);
continue;
}
hideView(view, true);
}
while (!staleViews.isEmpty()) {
trackedUnlockedStockSurfaces.remove(staleViews.removeFirst());
}
}
private boolean hasTrackedUnlockedStockSurfaces(View root) {
if (trackedUnlockedStockSurfaces.isEmpty()) {
return false;
}
for (View view : trackedUnlockedStockSurfaces) {
if (view != null && view.isAttachedToWindow() && isDescendantOf(view, root)) {
return true;
}
}
return false;
}
private void hideConfirmedAodPluginViews() {
hideTrackedViews(confirmedAodPluginViews, view -> true, false);
}
private void trackAndHideLockedStatusBarIconSurfaces(View root) {
walkTree(root, view -> {
if (isLockedStatusBarIconSurface(view)) {
trackedLockedStatusBarIconSurfaces.add(view);
hideView(view);
}
});
}
private void hideOrDiscoverLockedStatusBarIconSurfaces(View root) {
if (!hideTrackedLockedStatusBarIconSurfaces(root)) {
trackAndHideLockedStatusBarIconSurfaces(root);
}
}
private boolean hideTrackedLockedStatusBarIconSurfaces(View root) {
if (trackedLockedStatusBarIconSurfaces.isEmpty()) {
return false;
}
boolean foundInRoot = false;
ArrayDeque<View> staleViews = new ArrayDeque<>();
for (View view : trackedLockedStatusBarIconSurfaces) {
if (view == null || !view.isAttachedToWindow() || !isLockedStatusBarIconSurface(view)) {
staleViews.add(view);
continue;
}
if (root == null || isDescendantOf(view, root)) {
foundInRoot = true;
hideView(view);
}
}
while (!staleViews.isEmpty()) {
trackedLockedStatusBarIconSurfaces.remove(staleViews.removeFirst());
}
return foundInRoot;
}
private boolean hasTrackedStatusChipSources(View root) {
if (trackedStatusChipSources.isEmpty()) {
return false;
}
for (View view : trackedStatusChipSources) {
if (view != null
&& view.isAttachedToWindow()
&& isDescendantOf(view, root)
&& isStatusChipSource(view)) {
return true;
}
}
return false;
}
private void hideTrackedViews(
Set<View> trackedViews,
Predicate<View> keepTracked,
boolean keepLaidOut
) {
if (trackedViews.isEmpty()) {
return;
}
ArrayDeque<View> staleViews = new ArrayDeque<>();
for (View view : trackedViews) {
if (view == null || !view.isAttachedToWindow() || !keepTracked.test(view)) {
staleViews.add(view);
continue;
}
hideView(view, keepLaidOut);
}
while (!staleViews.isEmpty()) {
trackedViews.remove(staleViews.removeFirst());
}
}
private boolean isLockedStatusBarIconSurface(View view) {
if (view == null || ownedIconHostManager.isOwnedHost(view)) {
return false;
}
String idName = resolveIdName(view);
String viewClassName = view.getClass().getName();
boolean shelfSurface = viewClassName.contains("NotificationShelf");
boolean shadeIconOnlySurface = "notification_icononly_container".equals(idName)
|| "notification_icononly_area".equals(idName)
|| "keyguard_icononly_container_view".equals(idName)
|| "common_notification_widget".equals(idName);
if (!("system_icons".equals(idName)
|| "system_icon_area".equals(idName)
|| "statusIcons".equals(idName)
|| "battery".equals(idName)
|| "notification_icon_area".equals(idName)
|| "notification_icon_area_inner".equals(idName)
|| "notificationIcons".equals(idName)
|| "notification_icononly_container".equals(idName)
|| "notification_icononly_area".equals(idName)
|| "keyguard_icononly_container_view".equals(idName)
|| "common_notification_widget".equals(idName)
|| shelfSurface)) {
return false;
}
Object current = view;
while (current instanceof View currentView) {
String className = currentView.getClass().getName();
if (className.contains("KeyguardStatusBarView")
|| className.contains("PhoneStatusBarView")
|| className.contains("StatusBarWindowView")) {
return true;
}
if (shelfSurface && className.contains("NotificationShadeWindowView")) {
return true;
}
if (shadeIconOnlySurface && className.contains("NotificationShadeWindowView")) {
return true;
}
current = currentView.getParent();
}
return false;
}
private boolean canContainLockedStatusBarIconSurfaces(View root) {
if (root == null) {
return false;
}
String className = root.getClass().getName();
return className.contains("NotificationShadeWindowView")
|| className.contains("KeyguardStatusBarView")
|| className.contains("PhoneStatusBarView")
|| className.contains("StatusBarWindowView");
}
private boolean shouldHideNonChipView(View view) {
if (view == null) {
return false;
}
if (ownedIconHostManager.isOwnedHost(view)) {
return false;
}
if (confirmedAodPluginViews.contains(view)) {
return true;
}
String idName = resolveIdName(view);
if (TARGET_ID_NAMES.contains(idName)) {
return true;
}
String className = view.getClass().getName();
if (className.contains("NotificationShelf")) {
return true;
}
for (String token : TARGET_CLASS_SUBSTRINGS) {
if (className.contains(token)) {
return true;
}
}
return false;
}
private boolean isStatusChipSource(View view) {
if (view == null || view.getVisibility() != View.VISIBLE) {
return false;
}
if (ReflectionSupport.getBooleanField(view, "sbtStatusChipForcedHidden", false)) {
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;
}
int screenWidth = view.getResources().getDisplayMetrics().widthPixels;
if (screenWidth > 0 && width >= screenWidth / 2) {
return false;
}
if (!isStatusChipCandidate(view)) {
return false;
}
Object parent = view.getParent();
while (parent instanceof View parentView) {
if (isStatusChipCandidate(parentView)) {
return false;
}
parent = parentView.getParent();
}
return true;
}
private boolean isStatusChipCandidate(View view) {
String className = view.getClass().getName().toLowerCase(java.util.Locale.ROOT);
String idName = resolveIdName(view).toLowerCase(java.util.Locale.ROOT);
if (className.contains("privacy") || idName.contains("privacy")) {
return false;
}
return className.contains("touchinterceptframelayout")
|| className.contains("ongoingactivity")
|| "ongoing_activity_capsule".equals(idName);
}
private void markRootSizeBeforeDraw(View root) {
if (!isUnlockedStatusBarRoot(root)) {
return;
}
markRootSize(root, new RootSize(root.getWidth(), root.getHeight()));
}
private boolean isKnownRootSizeChanged(View root) {
if (!isUnlockedStatusBarRoot(root)) {
return false;
}
RootSize previous = rootSizes.get(root);
return previous != null
&& (previous.width != root.getWidth() || previous.height != root.getHeight());
}
private void markRootSize(View root, RootSize size) {
if (size.width <= 0 || size.height <= 0) {
return;
}
RootSize previous = rootSizes.put(root, size);
if (previous != null && !previous.equals(size)) {
unlockedIconRenderController.beginRootTransition();
}
}
private void enforceDirectAodControllerPolicy(Object controller) {
if (controller == null) {
return;
}
applyConfirmedAodPluginPolicy(castView(ReflectionSupport.requireFieldValue(controller, "d")));
applyConfirmedAodPluginPolicy(castView(ReflectionSupport.requireFieldValue(controller, "l")));
applyConfirmedAodPluginPolicy(castView(ReflectionSupport.requireFieldValue(controller, "k")));
applyConfirmedAodPluginPolicy(castView(ReflectionSupport.requireFieldValue(controller, "m")));
applyConfirmedAodPluginPolicy(castView(ReflectionSupport.requireFieldValue(controller, "s")));
}
private void enforceIconsOnlyControllerPolicy(Object controller) {
if (controller == null) {
return;
}
View container = castView(ReflectionSupport.requireFieldValue(controller, "mNotificationContainer"));
applyConfirmedAodPluginPolicy(container);
}
private void applyConfirmedAodPluginPolicy(View view) {
if (view == null) {
return;
}
applyConditionalAodPluginPolicy(view, isLayoutEnabled(view.getContext()));
}
private void applyConditionalAodPluginPolicy(View view, boolean shouldHide) {
if (shouldHide) {
confirmedAodPluginViews.add(view);
hideView(view);
} else {
confirmedAodPluginViews.remove(view);
restoreView(view);
}
}
private boolean hideView(View view) {
return hideView(view, false);
}
private boolean hideView(View view, boolean keepLaidOut) {
if (view == null) {
return false;
}
int targetVisibility = keepLaidOut ? View.VISIBLE : View.GONE;
if (hiddenViewStates.containsKey(view)
&& view.getVisibility() == targetVisibility
&& view.getAlpha() == 0f) {
return false;
}
hiddenViewStates.computeIfAbsent(view, ignored -> new HiddenViewState(
view.getVisibility(),
view.getAlpha()));
view.setAlpha(0f);
view.setVisibility(targetVisibility);
return true;
}
private boolean restoreView(View view) {
if (view == null) {
return false;
}
HiddenViewState state = hiddenViewStates.remove(view);
if (state == null) {
return false;
}
view.setAlpha(state.alpha);
view.setVisibility(state.visibility);
return true;
}
private void releaseHiddenViews() {
ArrayDeque<View> views = new ArrayDeque<>(hiddenViewStates.keySet());
while (!views.isEmpty()) {
View view = views.removeFirst();
hiddenViewStates.remove(view);
view.setAlpha(1f);
view.setVisibility(View.VISIBLE);
}
hiddenViewStates.clear();
trackedStatusChipSources.clear();
trackedLockedStatusBarIconSurfaces.clear();
trackedUnlockedStockSurfaces.clear();
}
private void cleanupLayoutOffRoot(View root) {
if (!hiddenViewStates.isEmpty()) {
releaseHiddenViews();
}
trackedStatusChipSources.clear();
trackedLockedStatusBarIconSurfaces.clear();
trackedUnlockedStockSurfaces.clear();
if (!isUnlockedStatusBarRoot(root) || !layoutOffCleanedRoots.add(root)) {
return;
}
rootSizes.remove(root);
unlockedIconRenderController.clearAll();
ownedIconHostManager.removeUnlockedHosts(root);
}
private boolean isDescendantOf(View child, View ancestor) {
if (child == null || ancestor == null) {
return false;
}
View current = child;
while (current != null) {
if (current == ancestor) {
return true;
}
Object parent = current.getParent();
current = parent instanceof View ? (View) parent : null;
}
return false;
}
private void walkTree(View root, ViewVisitor visitor) {
if (root == null || visitor == null) {
return;
}
ArrayDeque<View> queue = new ArrayDeque<>();
queue.add(root);
while (!queue.isEmpty()) {
View view = queue.removeFirst();
visitor.visit(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);
}
}
}
}
}
private UnlockedHosts updateUnlockedOwnedHosts(
View root,
SceneKey activeScene
) {
if (!isUnlockedStatusBarRoot(root)) {
return null;
}
if (activeScene != null && activeScene.isUnlocked()) {
UnlockedHosts cached = unlockedHostsByRoot.get(root);
if (areUnlockedHostsAttached(root, cached)) {
return cached;
}
View statusHost = ownedIconHostManager.ensureUnlockedStatusHost(root);
View notificationHost = ownedIconHostManager.ensureUnlockedNotificationHost(root);
View clockHost = ownedIconHostManager.ensureUnlockedClockHost(root);
View chipHost = ownedIconHostManager.ensureUnlockedChipHost(root);
if (statusHost instanceof ViewGroup statusGroup
&& notificationHost instanceof ViewGroup notificationGroup
&& clockHost instanceof ViewGroup clockGroup
&& chipHost instanceof ViewGroup chipGroup) {
UnlockedHosts hosts = new UnlockedHosts(clockGroup, chipGroup, statusGroup, notificationGroup);
unlockedHostsByRoot.put(root, hosts);
return hosts;
}
unlockedHostsByRoot.remove(root);
return null;
}
unlockedIconRenderController.clearAll();
ownedIconHostManager.removeUnlockedHosts(root);
unlockedHostsByRoot.remove(root);
return null;
}
private boolean areUnlockedHostsAttached(View root, UnlockedHosts hosts) {
return hosts != null
&& hosts.clockHost.getParent() == root
&& hosts.chipHost.getParent() == root
&& hosts.statusHost.getParent() == root
&& hosts.notificationHost.getParent() == root;
}
private boolean isUnlockedStatusBarRoot(View root) {
if (root == null) {
return false;
}
String className = root.getClass().getName();
return className.contains("PhoneStatusBarView")
|| className.contains("StatusBarWindowView");
}
private void updateActiveScene(View root, Context context) {
if (root == null) {
return;
}
if (runtimeContext.getModeStateRepository().hasSystemUiStatusBarState()) {
return;
}
if (isAodRoot(root) || (isKeyguardLocked(context) && !isInteractive(context))) {
runtimeContext.getModeStateRepository().setAod(NotificationDisplayStyle.UNKNOWN);
return;
}
if (isKeyguardLocked(context)) {
runtimeContext.getModeStateRepository().setLockscreen(NotificationDisplayStyle.UNKNOWN);
return;
}
runtimeContext.getModeStateRepository().setUnlocked();
}
private boolean isAodRoot(View root) {
return root != null && root.getClass().getName().toLowerCase(java.util.Locale.ROOT).contains("aod");
}
private View castView(Object value) {
return value instanceof View ? (View) value : null;
}
private Object firstArg(java.util.List<Object> args) {
return args != null && !args.isEmpty() ? args.get(0) : null;
}
private String resolveIdName(View view) {
if (view == null) {
return "";
}
int id = view.getId();
if (id == View.NO_ID) {
return "";
}
try {
return view.getResources().getResourceEntryName(id);
} catch (Resources.NotFoundException ignored) {
return "";
}
}
private boolean isKeyguardLocked(Context context) {
if (context == null) {
return false;
}
KeyguardManager keyguardManager = context.getSystemService(KeyguardManager.class);
return keyguardManager != null && keyguardManager.isKeyguardLocked();
}
private boolean isInteractive(Context context) {
if (context == null) {
return true;
}
PowerManager powerManager = context.getSystemService(PowerManager.class);
return powerManager == null || powerManager.isInteractive();
}
private boolean isLayoutEnabled(Context context) {
SbtSettings settings = RuntimeSettingsCache.get(context);
return settings.clockEnabled;
}
@FunctionalInterface
private interface ViewVisitor {
void visit(View view);
}
private static final class HiddenViewState {
final int visibility;
final float alpha;
HiddenViewState(int visibility, float alpha) {
this.visibility = visibility;
this.alpha = alpha;
}
}
private record UnlockedHosts(
ViewGroup clockHost,
ViewGroup chipHost,
ViewGroup statusHost,
ViewGroup notificationHost
) {
}
private record RootSize(int width, int height) {
}
}
@@ -0,0 +1,38 @@
package se.ajpanton.statusbartweak.runtime.layout;
import io.github.libxposed.api.XposedModuleInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.features.RuntimeFeature;
/**
* First layout-engine behavioral slice: clear stock icon surfaces so the new
* layout runtime starts from an empty canvas when Layout is enabled.
*/
public final class StockLayoutCanvasFeature implements RuntimeFeature {
private static final String PACKAGE_SYSTEM_UI = "com.android.systemui";
private StockLayoutCanvasController systemUiController;
@Override
public String getName() {
return "stock-layout-canvas";
}
@Override
public void onPackageReady(RuntimeContext context, XposedModuleInterface.PackageReadyParam param) {
if (param == null) {
return;
}
installForPackage(context, param.getPackageName(), param.getClassLoader());
}
private void installForPackage(RuntimeContext context, String packageName, ClassLoader classLoader) {
if (PACKAGE_SYSTEM_UI.equals(packageName)) {
if (systemUiController == null) {
systemUiController = new StockLayoutCanvasController(context);
}
systemUiController.install(classLoader);
}
}
}
@@ -0,0 +1,751 @@
package se.ajpanton.statusbartweak.runtime.layoutsolver;
import java.util.ArrayList;
import java.util.Collections;
public final class StatusBarLayoutSolver {
private static final int MIDDLE_COLLISION_MARGIN_PX = 2;
private StatusBarLayoutSolver() {
}
public static <T extends LayoutBand> ArrayList<T> bandsForPosition(
ArrayList<T> bands,
LayoutPosition position,
ArrayList<LayoutItemKind> itemOrder
) {
ArrayList<T> result = new ArrayList<>();
for (LayoutItemKind kind : itemOrder) {
for (T band : bands) {
if (band.kind == kind
&& band.position == position
&& band.splitRegion == SplitRegion.NONE) {
result.add(band);
}
}
}
return result;
}
public static <T extends LayoutBand> ArrayList<T> middleBandsForPlacement(
ArrayList<T> bands,
ArrayList<LayoutItemKind> itemOrder,
AnchorSide side
) {
ArrayList<T> result = new ArrayList<>();
SplitRegion sideRegion = side == AnchorSide.LEFT ? SplitRegion.LEFT : SplitRegion.RIGHT;
for (T band : bands) {
if (band.splitRegion == sideRegion) {
result.add(band);
}
}
result.addAll(bandsForPosition(bands, LayoutPosition.MIDDLE, itemOrder));
return result;
}
public static <T extends LayoutBand> RegionPlan<T> planRegions(
int rootWidth,
LayoutEdges edges,
ArrayList<T> bands,
ArrayList<LayoutItemKind> itemOrder,
Bounds cutoutBounds,
int itemPadding
) {
int leftEdge = Math.max(0, Math.min(rootWidth, edges.left));
int rightEdge = Math.max(leftEdge, Math.min(rootWidth, edges.right));
Region<T> leftRegion;
Region<T> rightRegion = null;
if (cutoutBounds == null) {
leftRegion = new Region<>(leftEdge, rightEdge);
} else {
leftRegion = new Region<>(leftEdge, Math.min(cutoutBounds.left, rightEdge));
rightRegion = new Region<>(
Math.max(cutoutBounds.left + cutoutBounds.width, leftEdge),
rightEdge);
}
ArrayList<T> leftBands = bandsForPosition(bands, LayoutPosition.LEFT, itemOrder);
ArrayList<T> middleBands = bandsForPosition(bands, LayoutPosition.MIDDLE, itemOrder);
ArrayList<T> rightBands = bandsForPosition(bands, LayoutPosition.RIGHT, itemOrder);
AnchorSide middleSide = sideForMiddleRegion(rootWidth, middleBands, cutoutBounds, itemPadding);
if (rightRegion == null) {
addSplitRegionBands(bands, leftRegion, SplitRegion.LEFT);
addSplitRegionBands(bands, leftRegion, SplitRegion.RIGHT);
leftRegion.bands.addAll(leftBands);
leftRegion.bands.addAll(middleBands);
Collections.reverse(rightBands);
leftRegion.bands.addAll(rightBands);
} else {
leftRegion.bands.addAll(leftBands);
ArrayList<T> middleRegionBands = new ArrayList<>(middleBands);
Collections.reverse(middleRegionBands);
if (middleSide == AnchorSide.LEFT) {
leftRegion.bands.addAll(middleRegionBands);
}
addSplitRegionBands(bands, leftRegion, SplitRegion.LEFT);
addSplitRegionBands(bands, rightRegion, SplitRegion.RIGHT);
if (middleSide == AnchorSide.RIGHT) {
rightRegion.bands.addAll(middleRegionBands);
}
Collections.reverse(rightBands);
rightRegion.bands.addAll(rightBands);
}
ArrayList<Region<T>> regions = new ArrayList<>();
regions.add(leftRegion);
if (rightRegion != null) {
regions.add(rightRegion);
}
return new RegionPlan<>(regions, middleSide);
}
private static <T extends LayoutBand> void addSplitRegionBands(
ArrayList<T> source,
Region<T> target,
SplitRegion region
) {
if (source == null || target == null || region == SplitRegion.NONE) {
return;
}
for (T band : source) {
if (band.splitRegion == region) {
target.bands.add(band);
}
}
}
public static <T extends LayoutBand> AnchorSide sideForMiddleRegion(
int rootWidth,
ArrayList<T> middleBands,
Bounds cutoutBounds,
int itemPadding
) {
int middle = rootWidth / 2;
if (middleBands == null || middleBands.isEmpty()) {
return cutoutBounds == null || cutoutBounds.left + cutoutBounds.width / 2 < middle
? AnchorSide.RIGHT
: AnchorSide.LEFT;
}
T firstBand = middleBands.get(0);
int width = packedWidth(middleBands, itemPadding);
int cameraDistance = distanceFromMiddle(cutoutBounds, middle);
boolean centered = cutoutBounds != null
&& cutoutBounds.left <= middle + MIDDLE_COLLISION_MARGIN_PX
&& cutoutBounds.left + cutoutBounds.width >= middle - MIDDLE_COLLISION_MARGIN_PX;
if (cameraDistance < width / 2 && (centered || !firstBand.middleAutoNearestSide)) {
return firstBand.middleSide;
}
if (cutoutBounds == null || cutoutBounds.left + cutoutBounds.width / 2 < middle) {
return AnchorSide.RIGHT;
}
return AnchorSide.LEFT;
}
public static <T extends LayoutBand> Anchor anchorForMiddle(
int rootWidth,
ArrayList<T> bands,
ArrayList<PlacedBand> placedBands,
Bounds cutoutBounds,
AnchorSide side,
int itemPadding
) {
int middle = rootWidth / 2;
int width = packedWidth(bands, itemPadding);
if (coversMiddle(cutoutBounds, middle)) {
return obstacleAnchor(cutoutBounds, side, AnchorKind.WALL);
}
Bounds nearestItem = nearestPlacedBand(placedBands, middle);
int cameraDistance = distanceFromMiddle(cutoutBounds, middle);
int itemDistance = distanceFromMiddle(nearestItem, middle);
if (Math.min(cameraDistance, itemDistance) < width / 2) {
if (cameraDistance < itemDistance) {
return obstacleAnchor(cutoutBounds, side, AnchorKind.WALL);
}
return obstacleAnchor(nearestItem, side, AnchorKind.ITEM);
}
return middleAnchor(rootWidth, width, side);
}
private static Anchor obstacleAnchor(Bounds obstacle, AnchorSide side, AnchorKind kind) {
if (obstacle == null) {
return new Anchor(0, side, kind);
}
int anchorPoint = side == AnchorSide.RIGHT ? obstacle.left + obstacle.width : obstacle.left;
return new Anchor(anchorPoint, side, kind);
}
private static Anchor middleAnchor(int rootWidth, int width, AnchorSide side) {
int middle = rootWidth / 2;
if (side == AnchorSide.RIGHT) {
return new Anchor(middle - width / 2, AnchorSide.RIGHT, AnchorKind.CENTER);
}
return new Anchor(middle + width / 2, AnchorSide.LEFT, AnchorKind.CENTER);
}
public static <T extends LayoutBand> void placeStackedBands(
ArrayList<T> bands,
Anchor anchor,
ArrayList<PlacedBand> placedBands,
String sortOrder,
boolean avoidPlacedBands,
int itemPadding
) {
if (bands == null || bands.isEmpty()) {
return;
}
for (T band : bands) {
band.prepareForSide(anchor.side, sortOrder);
}
ArrayList<StackedBand<T>> stackedBands = stackBands(bands, itemPadding);
int shift = avoidPlacedBands ? middleShift(stackedBands, anchor, placedBands, itemPadding) : 0;
for (StackedBand<T> stackedBand : stackedBands) {
T band = stackedBand.band;
int wallDotSlack = anchor.kind == AnchorKind.WALL
&& stackedBand.left == 0
&& shift == 0
? band.wallDotSlack(anchor.side)
: 0;
int left = stackedLeft(stackedBand, anchor, shift, wallDotSlack, itemPadding);
band.place(left);
ArrayList<Bounds> bounds = band.placedCollisionBounds();
if (!bounds.isEmpty()) {
placedBands.add(new PlacedBand(band.kind, band.position, bounds));
}
}
}
private static <T extends LayoutBand> int middleShift(
ArrayList<StackedBand<T>> stackedBands,
Anchor anchor,
ArrayList<PlacedBand> placedBands,
int itemPadding
) {
int shift = 0;
for (StackedBand<T> stackedBand : stackedBands) {
ArrayList<Bounds> bounds = boundsForStackedBand(stackedBand, anchor, shift, itemPadding);
int leftOverstep = overstepFromPlacedBands(bounds, placedBands, LayoutPosition.LEFT, itemPadding);
if (leftOverstep > 0) {
shift += leftOverstep;
continue;
}
int rightOverstep = overstepFromPlacedBands(bounds, placedBands, LayoutPosition.RIGHT, itemPadding);
if (rightOverstep > 0) {
shift -= rightOverstep;
}
}
return shift;
}
private static <T extends LayoutBand> ArrayList<Bounds> boundsForStackedBand(
StackedBand<T> stackedBand,
Anchor anchor,
int shift,
int itemPadding
) {
ArrayList<Bounds> result = new ArrayList<>();
int left = stackedLeft(stackedBand, anchor, shift, 0, itemPadding);
for (Bounds box : stackedBand.boxes) {
result.add(new Bounds(left + box.left, box.top, box.width, box.height));
}
return result;
}
private static <T extends LayoutBand> int stackedLeft(
StackedBand<T> stackedBand,
Anchor anchor,
int shift,
int wallDotSlack,
int itemPadding
) {
T band = stackedBand.band;
int anchorGap = anchor.kind == AnchorKind.ITEM ? Math.max(0, itemPadding) : 0;
if (anchor.side == AnchorSide.RIGHT) {
return anchor.point + anchorGap + stackedBand.left + shift - wallDotSlack;
}
return anchor.point - anchorGap - stackedBand.left - band.width() + shift + wallDotSlack;
}
private static int overstepFromPlacedBands(
ArrayList<Bounds> bounds,
ArrayList<PlacedBand> placedBands,
LayoutPosition position,
int itemPadding
) {
int overstep = 0;
if (bounds == null || placedBands == null) {
return overstep;
}
for (Bounds boundsItem : bounds) {
if (boundsItem == null) {
continue;
}
for (PlacedBand placedBand : placedBands) {
if (placedBand.position != position) {
continue;
}
for (Bounds obstacle : placedBand.bounds) {
if (obstacle == null
|| !verticalRangesOverlap(
boundsItem.top,
boundsItem.height,
obstacle.top,
obstacle.height)) {
continue;
}
overstep = Math.max(overstep, horizontalOverstep(boundsItem, obstacle, position, itemPadding));
}
}
}
return overstep;
}
private static int horizontalOverstep(
Bounds item,
Bounds obstacle,
LayoutPosition obstaclePosition,
int itemPadding
) {
int gap = Math.max(0, itemPadding);
if (obstaclePosition == LayoutPosition.LEFT) {
return Math.max(0, obstacle.left + blockingWidth(obstacle) + gap - item.left);
}
if (obstaclePosition == LayoutPosition.RIGHT) {
return Math.max(0, item.left + blockingWidth(item) + gap - obstacle.left);
}
return 0;
}
public static <T extends LayoutBand> void truncateToFit(
ArrayList<T> bands,
int freeSpace,
AnchorSide middleSide,
String sortOrder,
int itemPadding
) {
int target = Math.max(0, freeSpace);
for (T band : bands) {
band.prepareForSide(truncationAnchorSide(band, middleSide), sortOrder);
}
int guard = 64;
while (packedWidthForTruncation(bands, middleSide, itemPadding) > target && guard-- > 0) {
if (shrinkToReducePackedWidth(bands, LayoutItemKind.NOTIFICATIONS, middleSide, itemPadding)) {
continue;
}
if (shrinkToReducePackedWidth(bands, LayoutItemKind.STATUS, middleSide, itemPadding)) {
continue;
}
if (clipToReducePackedWidth(bands, LayoutItemKind.CHIP, middleSide, target, itemPadding)) {
continue;
}
if (removeDotToReducePackedWidth(bands, LayoutItemKind.NOTIFICATIONS, middleSide, itemPadding)) {
continue;
}
if (removeDotToReducePackedWidth(bands, LayoutItemKind.STATUS, middleSide, itemPadding)) {
continue;
}
if (clipToReducePackedWidth(bands, LayoutItemKind.CLOCK, middleSide, target, itemPadding)) {
continue;
}
break;
}
}
private static <T extends LayoutBand> boolean shrinkToReducePackedWidth(
ArrayList<T> bands,
LayoutItemKind kind,
AnchorSide middleSide,
int itemPadding
) {
return mutateBandToReducePackedWidth(
bands,
kind,
middleSide,
itemPadding,
LayoutBand::shrinkOneIcon,
false,
true);
}
private static <T extends LayoutBand> boolean removeDotToReducePackedWidth(
ArrayList<T> bands,
LayoutItemKind kind,
AnchorSide middleSide,
int itemPadding
) {
return mutateBandToReducePackedWidth(
bands,
kind,
middleSide,
itemPadding,
LayoutBand::removeOverflowDot,
true,
false);
}
private static <T extends LayoutBand> boolean mutateBandToReducePackedWidth(
ArrayList<T> bands,
LayoutItemKind kind,
AnchorSide middleSide,
int itemPadding,
BandMutation mutation,
boolean acceptEqualWidth,
boolean acceptCreatedDot
) {
T band = findBand(bands, kind);
if (band == null) {
return false;
}
int before = packedWidthForTruncation(bands, middleSide, itemPadding);
Object state = band.captureState();
if (!mutation.apply(band)) {
return false;
}
int after = packedWidthForTruncation(bands, middleSide, itemPadding);
if (after < before
|| (acceptEqualWidth && after == before)
|| (acceptCreatedDot && band.createdOverflowDotSince(state))) {
return true;
}
band.restoreState(state);
return false;
}
private static <T extends LayoutBand> boolean clipToReducePackedWidth(
ArrayList<T> bands,
LayoutItemKind kind,
AnchorSide middleSide,
int target,
int itemPadding
) {
T band = findBand(bands, kind);
if (band == null || !band.canClipContinuously()) {
return false;
}
int before = packedWidthForTruncation(bands, middleSide, itemPadding);
int amount = Math.max(1, before - target);
if (!band.clipContinuous(truncationAnchorSide(band, middleSide), amount)) {
return false;
}
return packedWidthForTruncation(bands, middleSide, itemPadding) <= before;
}
public static <T extends LayoutBand> int packedWidth(ArrayList<T> bands) {
return packedWidth(bands, 0);
}
public static <T extends LayoutBand> int packedWidth(ArrayList<T> bands, int itemPadding) {
return stackBounds(stackBands(bands, itemPadding)).width;
}
private static <T extends LayoutBand> int packedWidthForTruncation(
ArrayList<T> bands,
AnchorSide middleSide,
int itemPadding
) {
ArrayList<StackedBand<T>> stackedBands = stackBands(bands, itemPadding);
Bounds bounds = stackBounds(stackedBands);
int width = bounds.width;
if (width <= 0 || stackedBands.isEmpty()) {
return width;
}
int leftEdge = bounds.left;
int rightEdge = bounds.left + bounds.width;
Integer leftSlack = null;
Integer rightSlack = null;
for (StackedBand<T> stackedBand : stackedBands) {
T band = stackedBand.band;
Bounds bandBounds = shiftedBounds(stackedBand.boxes, stackedBand.left);
int bandLeft = bandBounds != null ? bandBounds.left : stackedBand.left;
int bandRight = bandBounds != null ? bandBounds.left + bandBounds.width : bandLeft;
if (bandLeft == leftEdge) {
int slack = band.wallDotSlack(AnchorSide.RIGHT);
leftSlack = leftSlack == null ? slack : Math.min(leftSlack, slack);
}
if (bandRight == rightEdge) {
int slack = band.wallDotSlack(AnchorSide.LEFT);
rightSlack = rightSlack == null ? slack : Math.min(rightSlack, slack);
}
}
return Math.max(0, width - (leftSlack != null ? leftSlack : 0) - (rightSlack != null ? rightSlack : 0));
}
private static AnchorSide truncationAnchorSide(LayoutBand band, AnchorSide middleSide) {
if (band.splitRegion == SplitRegion.LEFT) {
return AnchorSide.LEFT;
}
if (band.splitRegion == SplitRegion.RIGHT) {
return AnchorSide.RIGHT;
}
if (band.position == LayoutPosition.LEFT) {
return AnchorSide.RIGHT;
}
if (band.position == LayoutPosition.RIGHT) {
return AnchorSide.LEFT;
}
return middleSide;
}
private static <T extends LayoutBand> ArrayList<StackedBand<T>> stackBands(
ArrayList<T> bands,
int itemPadding
) {
ArrayList<StackedBand<T>> stackedBands = new ArrayList<>();
if (bands == null) {
return stackedBands;
}
int gap = Math.max(0, itemPadding);
for (T band : bands) {
int left = 0;
ArrayList<Bounds> boxes = band.collisionBoxes();
for (StackedBand<T> stackedBand : stackedBands) {
for (Bounds box : boxes) {
for (Bounds placedBox : stackedBand.boxes) {
if (verticalRangesOverlap(box.top, box.height, placedBox.top, placedBox.height)) {
left = Math.max(
left,
stackedBand.left + placedBox.left + blockingWidth(placedBox) + gap - box.left);
}
}
}
}
stackedBands.add(new StackedBand<>(band, left, boxes));
}
return stackedBands;
}
private static <T extends LayoutBand> Bounds stackBounds(ArrayList<StackedBand<T>> stackedBands) {
Bounds bounds = new Bounds(0, 0, 0, 0);
if (stackedBands == null || stackedBands.isEmpty()) {
return bounds;
}
Bounds union = null;
for (StackedBand<T> stackedBand : stackedBands) {
Bounds bandBounds = shiftedBounds(stackedBand.boxes, stackedBand.left);
if (bandBounds != null) {
union = union == null ? bandBounds : union.union(bandBounds);
}
}
return union != null ? union : bounds;
}
private static Bounds shiftedBounds(ArrayList<Bounds> boxes, int left) {
Bounds union = null;
if (boxes == null) {
return null;
}
for (Bounds box : boxes) {
Bounds shifted = new Bounds(left + box.left, box.top, blockingWidth(box), box.height);
union = union == null ? shifted : union.union(shifted);
}
return union;
}
private static boolean verticalRangesOverlap(int topA, int heightA, int topB, int heightB) {
return topA < topB + heightB && topB < topA + heightA;
}
private static int blockingWidth(Bounds bounds) {
return bounds != null && bounds.height > 0 ? Math.max(1, bounds.width) : 0;
}
private static <T extends LayoutBand> T findBand(ArrayList<T> bands, LayoutItemKind kind) {
for (T band : bands) {
if (band.kind == kind) {
return band;
}
}
return null;
}
private static Bounds nearestPlacedBand(ArrayList<PlacedBand> placedBands, int middle) {
Bounds nearest = null;
int nearestDistance = Integer.MAX_VALUE;
for (PlacedBand placedBand : placedBands) {
for (Bounds bounds : placedBand.bounds) {
int distance = distanceFromMiddle(bounds, middle);
if (distance < nearestDistance) {
nearest = bounds;
nearestDistance = distance;
}
}
}
return nearest;
}
public static int distanceFromMiddle(Bounds bounds, int middle) {
if (bounds == null) {
return Integer.MAX_VALUE;
}
if (bounds.left <= middle && bounds.left + bounds.width >= middle) {
return 0;
}
return Math.min(Math.abs(middle - bounds.left), Math.abs(middle - (bounds.left + bounds.width)));
}
public static boolean coversMiddle(Bounds bounds, int middle) {
return bounds != null
&& bounds.left <= middle + MIDDLE_COLLISION_MARGIN_PX
&& bounds.left + bounds.width >= middle - MIDDLE_COLLISION_MARGIN_PX;
}
public abstract static class LayoutBand {
public final LayoutItemKind kind;
public final LayoutPosition position;
public final boolean middleAutoNearestSide;
public final AnchorSide middleSide;
public final SplitRegion splitRegion;
protected LayoutBand(
LayoutItemKind kind,
LayoutPosition position,
boolean middleAutoNearestSide,
AnchorSide middleSide,
SplitRegion splitRegion
) {
this.kind = kind;
this.position = position;
this.middleAutoNearestSide = middleAutoNearestSide;
this.middleSide = middleSide;
this.splitRegion = splitRegion != null ? splitRegion : SplitRegion.NONE;
}
protected abstract void prepareForSide(AnchorSide side, String sortOrder);
protected abstract int width();
protected abstract ArrayList<Bounds> collisionBoxes();
protected abstract void place(int left);
protected abstract ArrayList<Bounds> placedCollisionBounds();
protected int wallDotSlack(AnchorSide side) {
return 0;
}
protected boolean shrinkOneIcon() {
return false;
}
protected boolean removeOverflowDot() {
return false;
}
protected Object captureState() {
return null;
}
protected void restoreState(Object state) {
}
protected boolean createdOverflowDotSince(Object state) {
return false;
}
protected boolean canClipContinuously() {
return false;
}
protected boolean clipContinuous(AnchorSide wallSide, int amountPx) {
return false;
}
}
private interface BandMutation {
boolean apply(LayoutBand band);
}
private record StackedBand<T extends LayoutBand>(
T band,
int left,
ArrayList<Bounds> boxes
) {
}
public enum LayoutItemKind {
CLOCK,
CHIP,
NOTIFICATIONS,
STATUS
}
public enum LayoutPosition {
LEFT,
MIDDLE,
RIGHT
}
public enum AnchorSide {
LEFT,
RIGHT
}
public enum AnchorKind {
WALL,
ITEM,
CENTER
}
public enum SplitRegion {
NONE,
LEFT,
RIGHT
}
public record Anchor(int point, AnchorSide side, AnchorKind kind) {
}
public static final class Bounds {
public final int left;
public final int top;
public final int width;
public final int height;
public Bounds(int left, int top, int width, int height) {
this.left = left;
this.top = top;
this.width = width;
this.height = height;
}
public Bounds union(Bounds other) {
int right = Math.max(left + width, other.left + other.width);
int bottom = Math.max(top + height, other.top + other.height);
int newLeft = Math.min(left, other.left);
int newTop = Math.min(top, other.top);
return new Bounds(newLeft, newTop, right - newLeft, bottom - newTop);
}
}
public record PlacedBand(
LayoutItemKind kind,
LayoutPosition position,
ArrayList<Bounds> bounds
) {
}
public record RegionPlan<T extends LayoutBand>(
ArrayList<Region<T>> regions,
AnchorSide middleSide
) {
}
public record LayoutEdges(int left, int right) {
}
public static final class Region<T extends LayoutBand> {
public final int left;
public final int right;
public final ArrayList<T> bands = new ArrayList<>();
Region(int left, int right) {
this.left = left;
this.right = Math.max(left, right);
}
public int width() {
return Math.max(0, right - left);
}
}
}
@@ -0,0 +1,91 @@
package se.ajpanton.statusbartweak.runtime.mode;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Central source of truth for the current scene.
*
* This is intentionally small at the reset stage: it only tracks the active
* scene and whether the custom layout runtime should currently output anything.
* Hook-driven updates can be wired in later without changing the rest of the
* runtime API.
*/
public final class ModeStateRepository {
private SceneKey activeScene = SceneKey.unlocked();
private boolean systemUiStatusBarStateKnown;
private boolean layoutEnabled;
private final List<LayoutEnabledListener> layoutEnabledListeners = new ArrayList<>();
public synchronized SceneKey getActiveScene() {
return activeScene;
}
public synchronized boolean hasSystemUiStatusBarState() {
return systemUiStatusBarStateKnown;
}
public synchronized boolean isLayoutEnabled() {
return layoutEnabled;
}
public void setLayoutEnabled(boolean enabled) {
List<LayoutEnabledListener> listeners;
synchronized (this) {
if (layoutEnabled == enabled) {
return;
}
layoutEnabled = enabled;
listeners = new ArrayList<>(layoutEnabledListeners);
}
for (LayoutEnabledListener listener : listeners) {
listener.onLayoutEnabledChanged(enabled);
}
}
public synchronized void addLayoutEnabledListener(LayoutEnabledListener listener) {
if (listener != null && !layoutEnabledListeners.contains(listener)) {
layoutEnabledListeners.add(listener);
}
}
public synchronized void setUnlocked() {
activeScene = SceneKey.unlocked();
}
public synchronized void setLockscreen(NotificationDisplayStyle style) {
activeScene = SceneKey.lockscreen(requireDisplayStyle(style));
}
public synchronized void setAod(NotificationDisplayStyle style) {
activeScene = SceneKey.aod(requireDisplayStyle(style));
}
public synchronized void setScene(SceneKey sceneKey) {
activeScene = Objects.requireNonNull(sceneKey, "sceneKey");
}
public synchronized void setSystemUiStatusBarState(int state, boolean dozing) {
systemUiStatusBarStateKnown = true;
if (dozing) {
activeScene = SceneKey.aod(NotificationDisplayStyle.UNKNOWN);
} else if (state == 0) {
activeScene = SceneKey.unlocked();
} else {
activeScene = SceneKey.lockscreen(NotificationDisplayStyle.UNKNOWN);
}
}
private static NotificationDisplayStyle requireDisplayStyle(NotificationDisplayStyle style) {
if (style == null || style == NotificationDisplayStyle.NONE) {
throw new IllegalArgumentException("Lockscreen/AOD scenes cannot use NONE");
}
return style;
}
public interface LayoutEnabledListener {
void onLayoutEnabledChanged(boolean enabled);
}
}
@@ -0,0 +1,15 @@
package se.ajpanton.statusbartweak.runtime.mode;
/**
* How Android is currently representing notifications for a scene.
*
* Unlocked mode does not use the lockscreen/AOD notification style setting, so
* it should use {@link #NONE}.
*/
public enum NotificationDisplayStyle {
UNKNOWN,
NONE,
ICONS,
CARDS,
DOT
}
@@ -0,0 +1,67 @@
package se.ajpanton.statusbartweak.runtime.mode;
import java.util.Objects;
/**
* Stable scene identity for the new runtime pipeline.
*
* Cards/icons/dot are notification display styles within lockscreen/AOD, not
* separate top-level modes. Unlocked scenes always use {@link
* NotificationDisplayStyle#NONE}.
*/
public record SceneKey(
SurfaceMode surfaceMode,
NotificationDisplayStyle notificationDisplayStyle
) {
public SceneKey {
surfaceMode = Objects.requireNonNull(surfaceMode, "surfaceMode");
notificationDisplayStyle = Objects.requireNonNull(
notificationDisplayStyle,
"notificationDisplayStyle"
);
if (surfaceMode == SurfaceMode.UNLOCKED
&& notificationDisplayStyle != NotificationDisplayStyle.NONE) {
throw new IllegalArgumentException(
"Unlocked scenes must use NotificationDisplayStyle.NONE"
);
}
if (surfaceMode != SurfaceMode.UNLOCKED
&& notificationDisplayStyle == NotificationDisplayStyle.NONE) {
throw new IllegalArgumentException(
"Lockscreen/AOD scenes cannot use NotificationDisplayStyle.NONE"
);
}
}
public static SceneKey unlocked() {
return new SceneKey(SurfaceMode.UNLOCKED, NotificationDisplayStyle.NONE);
}
public static SceneKey lockscreen(NotificationDisplayStyle style) {
return new SceneKey(SurfaceMode.LOCKSCREEN, requireStyle(style));
}
public static SceneKey aod(NotificationDisplayStyle style) {
return new SceneKey(SurfaceMode.AOD, requireStyle(style));
}
public boolean isUnlocked() {
return surfaceMode == SurfaceMode.UNLOCKED;
}
public boolean isLockscreen() {
return surfaceMode == SurfaceMode.LOCKSCREEN;
}
public boolean isAod() {
return surfaceMode == SurfaceMode.AOD;
}
private static NotificationDisplayStyle requireStyle(NotificationDisplayStyle style) {
if (style == null || style == NotificationDisplayStyle.NONE) {
throw new IllegalArgumentException("A non-NONE notification display style is required");
}
return style;
}
}
@@ -0,0 +1,10 @@
package se.ajpanton.statusbartweak.runtime.mode;
/**
* High-level SystemUI surface we are rendering into.
*/
public enum SurfaceMode {
UNLOCKED,
LOCKSCREEN,
AOD
}
@@ -0,0 +1,70 @@
package se.ajpanton.statusbartweak.runtime.mode;
import io.github.libxposed.api.XposedModuleInterface;
import se.ajpanton.statusbartweak.runtime.features.RuntimeContext;
import se.ajpanton.statusbartweak.runtime.features.RuntimeFeature;
import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport;
import se.ajpanton.statusbartweak.runtime.hooks.XposedHookSupport;
/**
* Feeds the runtime scene from SystemUI's own status-bar state controller.
*
* KeyguardManager lags during unlock/drawer transitions; StatusBarState is the
* state SystemUI itself uses for SHADE / KEYGUARD / SHADE_LOCKED decisions.
*/
public final class SystemUiStatusBarStateFeature implements RuntimeFeature {
private static final String PACKAGE_SYSTEM_UI = "com.android.systemui";
private static final String CLASS_STATUS_BAR_STATE_CONTROLLER =
"com.android.systemui.statusbar.StatusBarStateControllerImpl";
private boolean installed;
@Override
public String getName() {
return "systemui-status-bar-state";
}
@Override
public void onPackageReady(RuntimeContext context, XposedModuleInterface.PackageReadyParam param) {
if (param == null || installed || !PACKAGE_SYSTEM_UI.equals(param.getPackageName())) {
return;
}
Class<?> controllerClass = ReflectionSupport.findClassIfExists(
CLASS_STATUS_BAR_STATE_CONTROLLER,
param.getClassLoader());
if (controllerClass == null) {
return;
}
XposedHookSupport.hookAllConstructors(context.getFramework(), controllerClass, chain -> {
Object result = chain.proceed();
updateScene(context, chain.getThisObject());
return result;
});
XposedHookSupport.hookAllMethodsIfExists(context.getFramework(), controllerClass, "setState", chain -> {
Object result = chain.proceed();
updateScene(context, chain.getThisObject());
return result;
});
XposedHookSupport.hookAllMethodsIfExists(
context.getFramework(),
controllerClass,
"setDozeAmountInternal",
chain -> {
Object result = chain.proceed();
updateScene(context, chain.getThisObject());
return result;
});
installed = true;
}
private void updateScene(RuntimeContext context, Object controller) {
if (context == null || controller == null) {
return;
}
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);
}
}
@@ -0,0 +1,451 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import java.util.ArrayList;
import se.ajpanton.statusbartweak.runtime.features.clock.ClockLayoutTextFactory;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.AnchorSide;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.LayoutPosition;
final class ClockLayout {
private static final int MIDDLE_COLLISION_MARGIN_PX = 2;
private final TextView source;
private final ClockLayoutTextFactory.Model model;
private final ArrayList<ClockRow> rows;
private final ArrayList<MeasuredClockRow> measuredRows;
private final int top;
private final boolean fixedPlacement;
private final int width;
private final int height;
private int placedLeft;
private ClockLayout(
TextView source,
ClockLayoutTextFactory.Model model,
ArrayList<ClockRow> rows,
ArrayList<MeasuredClockRow> measuredRows,
int top,
int placedLeft,
boolean fixedPlacement,
int width,
int height
) {
this.source = source;
this.model = model;
this.rows = rows;
this.measuredRows = measuredRows;
this.top = top;
this.placedLeft = placedLeft;
this.fixedPlacement = fixedPlacement;
this.width = width;
this.height = height;
}
static ClockLayout measure(
TextView source,
ClockLayoutTextFactory.Model model,
LayoutPosition position,
int sourceBaselineY,
int rootWidth,
Bounds cutoutBounds,
boolean middleAutoNearestSide,
AnchorSide middleSide
) {
if (source == null || model == null || model.rows.isEmpty()) {
return null;
}
TextView probe = new TextView(source.getContext());
probe.setLayoutParams(new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT));
ClockProxyRenderer.copyTextStyleIfChanged(source, probe);
ArrayList<MeasuredClockRow> measuredRows = new ArrayList<>();
for (int i = 0; i < model.rows.size(); i++) {
ClockLayoutTextFactory.Row row = model.rows.get(i);
MeasuredText text = measureText(probe, row.text);
MeasuredText leftText = measureText(probe, row.leftText);
MeasuredText rightText = measureText(probe, row.rightText);
int rightPipe = text.width - rightText.width;
int pipePosition = row.pipeCount > 1
? Math.round((leftText.width + rightPipe) / 2f)
: leftText.width;
measuredRows.add(new MeasuredClockRow(
row,
text.width,
text.height,
text.baseline,
row.pipeCount > 0 ? pipePosition : 0,
0,
leftText,
rightText,
0,
i));
}
measuredRows = positionRowsFromBaseline(measuredRows, sourceBaselineY, source);
RowAlignment alignment = alignRows(measuredRows, position);
ArrayList<ClockRow> rows = alignment.rows;
Bounds bounds = null;
for (ClockRow row : rows) {
Bounds rowBounds = new Bounds(row.x, row.y, row.width, row.height);
bounds = bounds == null ? rowBounds : bounds.union(rowBounds);
}
if (bounds == null || alignment.width <= 0 || bounds.height <= 0) {
return null;
}
int top = bounds.top;
ArrayList<ClockRow> normalized = new ArrayList<>();
for (ClockRow row : rows) {
normalized.add(new ClockRow(
row.text,
row.x,
row.y - bounds.top,
row.width,
row.height,
0,
row.rowIndex,
row.segment));
}
return new ClockLayout(
source,
model,
normalized,
measuredRows,
top,
0,
false,
alignment.width,
bounds.height);
}
private static ArrayList<MeasuredClockRow> positionRowsFromBaseline(
ArrayList<MeasuredClockRow> measuredRows,
int sourceBaselineY,
TextView source
) {
ArrayList<MeasuredClockRow> positionedRows = new ArrayList<>(measuredRows);
int baselineY = sourceBaselineY;
for (int rowIndex = measuredRows.size() - 1; rowIndex >= 0; rowIndex--) {
MeasuredClockRow row = measuredRows.get(rowIndex);
int top = baselineY - row.baseline;
positionedRows.set(rowIndex, new MeasuredClockRow(
row.source,
row.width,
row.height,
row.baseline,
row.pipePosition,
baselineY,
row.leftSegment,
row.rightSegment,
top,
row.rowIndex));
if (rowIndex > 0) {
baselineY -= resolveGap(row.source.gapBeforePx, source);
}
}
return positionedRows;
}
private static boolean shouldSplitAroundCutout(
LayoutPosition position,
ArrayList<ClockRow> rows,
Bounds bounds,
int rootWidth,
Bounds cutoutBounds
) {
if (position != LayoutPosition.MIDDLE
|| cutoutBounds == null
|| rootWidth <= 0
|| rows == null
|| rows.isEmpty()) {
return false;
}
int left = rootWidth / 2 - bounds.width / 2;
for (ClockRow row : rows) {
int rowLeft = left + row.x;
int rowRight = rowLeft + row.width;
if (rowRight > cutoutBounds.left
&& rowLeft < cutoutBounds.left + cutoutBounds.width) {
return true;
}
}
return false;
}
boolean canSplitAroundCutout(int rootWidth, Bounds cutoutBounds) {
return shouldSplitAroundCutout(LayoutPosition.MIDDLE, rows, bounds(), rootWidth, cutoutBounds);
}
ArrayList<ClockLayout> splitAroundCutout(
int rootWidth,
Bounds cutoutBounds,
boolean middleAutoNearestSide,
AnchorSide explicitSide
) {
ArrayList<ClockLayout> result = new ArrayList<>();
if (measuredRows == null || measuredRows.isEmpty() || cutoutBounds == null) {
return result;
}
AnchorSide side = splitSide(rootWidth, cutoutBounds, middleAutoNearestSide, explicitSide);
ArrayList<ClockRow> leftRows = new ArrayList<>();
ArrayList<ClockRow> rightRows = new ArrayList<>();
for (MeasuredClockRow row : measuredRows) {
if (row.source.pipeCount > 0) {
addSplitSegment(
leftRows,
row.source.leftText,
row.leftSegment,
cutoutBounds.left,
false,
row.baselineY,
row.rowIndex,
ClockRowSegment.LEFT);
addSplitSegment(
rightRows,
row.source.rightText,
row.rightSegment,
cutoutBounds.left + cutoutBounds.width,
true,
row.baselineY,
row.rowIndex,
ClockRowSegment.RIGHT);
} else {
int x = side == AnchorSide.LEFT
? cutoutBounds.left - row.width
: cutoutBounds.left + cutoutBounds.width;
ArrayList<ClockRow> targetRows = side == AnchorSide.LEFT ? leftRows : rightRows;
targetRows.add(new ClockRow(
row.source.text,
x,
row.y,
row.width,
row.height,
0,
row.rowIndex,
ClockRowSegment.FULL));
}
}
ClockLayout left = fixedLayoutFromAbsoluteRows(leftRows);
ClockLayout right = fixedLayoutFromAbsoluteRows(rightRows);
if (left != null) {
result.add(left);
}
if (right != null) {
result.add(right);
}
return result;
}
private ClockLayout fixedLayoutFromAbsoluteRows(ArrayList<ClockRow> rows) {
Bounds bounds = null;
for (ClockRow row : rows) {
Bounds rowBounds = new Bounds(row.x, row.y, row.width, row.height);
bounds = bounds == null ? rowBounds : bounds.union(rowBounds);
}
if (bounds == null || bounds.width <= 0 || bounds.height <= 0) {
return null;
}
ArrayList<ClockRow> normalized = new ArrayList<>();
for (ClockRow row : rows) {
normalized.add(new ClockRow(
row.text,
row.x - bounds.left,
row.y - bounds.top,
row.width,
row.height,
0,
row.rowIndex,
row.segment));
}
return new ClockLayout(
source,
model,
normalized,
null,
bounds.top,
bounds.left,
true,
bounds.width,
bounds.height);
}
private static void addSplitSegment(
ArrayList<ClockRow> rows,
CharSequence text,
MeasuredText measuredText,
int anchor,
boolean extendRight,
int baselineY,
int rowIndex,
ClockRowSegment segment
) {
if (text == null || text.length() <= 0 || measuredText == null || measuredText.width <= 0) {
return;
}
int x = extendRight ? anchor : anchor - measuredText.width;
int y = baselineY - measuredText.baseline;
rows.add(new ClockRow(
text,
x,
y,
measuredText.width,
measuredText.height,
0,
rowIndex,
segment));
}
private static AnchorSide splitSide(
int rootWidth,
Bounds cutoutBounds,
boolean middleAutoNearestSide,
AnchorSide explicitSide
) {
if (!middleAutoNearestSide || cutoutBounds == null || rootWidth <= 0) {
return explicitSide;
}
int middle = rootWidth / 2;
int cutoutCenter = cutoutBounds.left + cutoutBounds.width / 2;
if (cutoutCenter > middle + MIDDLE_COLLISION_MARGIN_PX) {
return AnchorSide.LEFT;
}
if (cutoutCenter < middle - MIDDLE_COLLISION_MARGIN_PX) {
return AnchorSide.RIGHT;
}
return explicitSide;
}
private static RowAlignment alignRows(
ArrayList<MeasuredClockRow> measuredRows,
LayoutPosition position
) {
int maxPipeLeft = 0;
int maxPipeRight = 0;
int maxStandaloneWidth = 0;
for (MeasuredClockRow row : measuredRows) {
if (row.source.pipeCount > 0) {
maxPipeLeft = Math.max(maxPipeLeft, row.pipePosition);
maxPipeRight = Math.max(maxPipeRight, row.width - row.pipePosition);
} else {
maxStandaloneWidth = Math.max(maxStandaloneWidth, row.width);
}
}
int pipeGroupWidth = maxPipeLeft + maxPipeRight;
int alignedWidth = Math.max(maxStandaloneWidth, pipeGroupWidth);
int pipeGroupOffset = alignedOffset(alignedWidth, pipeGroupWidth, position);
ArrayList<ClockRow> rows = new ArrayList<>();
for (MeasuredClockRow row : measuredRows) {
int x;
if (row.source.pipeCount > 0) {
x = pipeGroupOffset + maxPipeLeft - row.pipePosition;
} else {
x = alignedOffset(alignedWidth, row.width, position);
}
rows.add(new ClockRow(
row.source.text,
x,
row.y,
row.width,
row.height,
0,
row.rowIndex,
ClockRowSegment.FULL));
}
return new RowAlignment(rows, alignedWidth);
}
private static int alignedOffset(int containerWidth, int contentWidth, LayoutPosition position) {
int free = Math.max(0, containerWidth - contentWidth);
if (position == LayoutPosition.RIGHT) {
return free;
}
if (position == LayoutPosition.MIDDLE) {
return free / 2;
}
return 0;
}
private static MeasuredText measureText(TextView view, CharSequence text) {
view.setText(text);
int widthSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
view.measure(widthSpec, heightSpec);
return new MeasuredText(
Math.max(0, view.getMeasuredWidth()),
Math.max(1, view.getMeasuredHeight()),
Math.max(0, view.getBaseline()));
}
private static int resolveGap(int gapBeforePx, TextView source) {
if (gapBeforePx == ClockLayoutTextFactory.ROW_GAP_AUTO) {
return Math.max(1, source.getLineHeight());
}
return gapBeforePx;
}
int width() {
return width;
}
int height() {
return height;
}
int top() {
return top;
}
Bounds bounds() {
return new Bounds(0, top, width, height);
}
ArrayList<Bounds> collisionBoxes() {
ArrayList<Bounds> boxes = new ArrayList<>();
if (rows == null) {
return boxes;
}
for (ClockRow row : rows) {
if (row.width > 0 && row.height > 0) {
boxes.add(new Bounds(row.x, top + row.y, row.width, row.height));
}
}
return boxes;
}
Bounds place(int left) {
if (!fixedPlacement) {
placedLeft = left;
}
return new Bounds(placedLeft, top, width, height);
}
ClockPlacement placement() {
return new ClockPlacement(source, model.contentDescription, rows, new Bounds(placedLeft, top, width, height));
}
private record MeasuredClockRow(
ClockLayoutTextFactory.Row source,
int width,
int height,
int baseline,
int pipePosition,
int baselineY,
MeasuredText leftSegment,
MeasuredText rightSegment,
int y,
int rowIndex
) {
}
private record MeasuredText(int width, int height, int baseline) {
}
private record RowAlignment(ArrayList<ClockRow> rows, int width) {
}
}
@@ -0,0 +1,197 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.WeakHashMap;
final class ClockProxyRenderer {
private final WeakHashMap<ViewGroup, ArrayList<TextView>> rowsByHost = new WeakHashMap<>();
void render(ViewGroup host, ClockPlacement placement) {
if (host == null || placement == null || placement.rows.isEmpty()) {
clear(host);
return;
}
ArrayList<TextView> views = rowsByHost.get(host);
if (views == null) {
views = new ArrayList<>();
rowsByHost.put(host, views);
}
boolean hostChanged = false;
if (!views.isEmpty() && views.size() != placement.rows.size()) {
for (TextView view : views) {
detachFromParent(view);
}
views.clear();
hostChanged = true;
}
while (views.size() > placement.rows.size()) {
TextView view = views.remove(views.size() - 1);
detachFromParent(view);
hostChanged = true;
}
while (views.size() < placement.rows.size()) {
TextView view = new TextView(host.getContext());
view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
views.add(view);
host.addView(view);
hostChanged = true;
}
for (int i = 0; i < placement.rows.size(); i++) {
ClockRow row = placement.rows.get(i);
TextView view = views.get(i);
if (copyTextStyleIfChanged(placement.source, view)) {
hostChanged = true;
}
if (!String.valueOf(view.getText()).contentEquals(row.text)) {
view.setText(row.text);
hostChanged = true;
}
if (!java.util.Objects.equals(view.getContentDescription(), placement.contentDescription)) {
view.setContentDescription(placement.contentDescription);
hostChanged = true;
}
if (view.getVisibility() != View.VISIBLE) {
view.setVisibility(View.VISIBLE);
hostChanged = true;
}
if (view.getAlpha() != 1f) {
view.setAlpha(1f);
hostChanged = true;
}
int left = placement.bounds.left + row.x;
int top = placement.bounds.top + row.y;
int scrollX = Math.max(0, row.clipLeft);
if (view.getScrollX() != scrollX) {
view.setScrollX(scrollX);
hostChanged = true;
}
if (applyTextLayoutParams(host, view, left, top, row.width, row.height)) {
hostChanged = true;
}
if (view.getLeft() != left
|| view.getTop() != top
|| view.getRight() != left + row.width
|| view.getBottom() != top + row.height) {
view.layout(left, top, left + row.width, top + row.height);
hostChanged = true;
}
}
if (hostChanged) {
host.requestLayout();
host.invalidate();
}
}
void clear(ViewGroup host) {
if (host == null) {
return;
}
ArrayList<TextView> views = rowsByHost.remove(host);
if (views == null) {
return;
}
for (TextView view : views) {
detachFromParent(view);
}
host.requestLayout();
host.invalidate();
}
void clearAll() {
for (ViewGroup host : new ArrayList<>(rowsByHost.keySet())) {
clear(host);
}
}
static boolean copyTextStyleIfChanged(TextView source, TextView target) {
boolean changed = false;
if (target.getCurrentTextColor() != source.getCurrentTextColor()) {
target.setTextColor(source.getCurrentTextColor());
changed = true;
}
if (target.getTextSize() != source.getTextSize()) {
target.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, source.getTextSize());
changed = true;
}
if (!java.util.Objects.equals(target.getTypeface(), source.getTypeface())) {
target.setTypeface(source.getTypeface());
changed = true;
}
if (target.getGravity() != source.getGravity()) {
target.setGravity(source.getGravity());
changed = true;
}
if (target.getIncludeFontPadding() != source.getIncludeFontPadding()) {
target.setIncludeFontPadding(source.getIncludeFontPadding());
changed = true;
}
if (target.getMaxLines() != 1) {
target.setSingleLine(true);
target.setMaxLines(1);
changed = true;
}
if (!target.isHorizontallyScrollable()) {
target.setHorizontallyScrolling(true);
changed = true;
}
if (target.getPaddingLeft() != source.getPaddingLeft()
|| target.getPaddingTop() != source.getPaddingTop()
|| target.getPaddingRight() != source.getPaddingRight()
|| target.getPaddingBottom() != source.getPaddingBottom()) {
target.setPadding(
source.getPaddingLeft(),
source.getPaddingTop(),
source.getPaddingRight(),
source.getPaddingBottom());
changed = true;
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
if (target.getLetterSpacing() != source.getLetterSpacing()) {
target.setLetterSpacing(source.getLetterSpacing());
changed = true;
}
if (!java.util.Objects.equals(target.getFontFeatureSettings(), source.getFontFeatureSettings())) {
target.setFontFeatureSettings(source.getFontFeatureSettings());
changed = true;
}
}
return changed;
}
private static boolean applyTextLayoutParams(
ViewGroup host,
TextView view,
int left,
int top,
int width,
int height
) {
ViewGroup.LayoutParams current = view.getLayoutParams();
if (current != null
&& current.width == width
&& current.height == height
&& current instanceof ViewGroup.MarginLayoutParams marginParams
&& marginParams.leftMargin == left
&& marginParams.topMargin == top) {
return false;
}
ViewGroup.MarginLayoutParams lp = host instanceof FrameLayout
? new FrameLayout.LayoutParams(width, height)
: new ViewGroup.MarginLayoutParams(width, height);
lp.leftMargin = left;
lp.topMargin = top;
view.setLayoutParams(lp);
return true;
}
private static void detachFromParent(View view) {
if (view != null && view.getParent() instanceof ViewGroup parent) {
parent.removeView(view);
}
}
}
@@ -0,0 +1,63 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.widget.TextView;
import java.util.ArrayList;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds;
final class ClockPlacement {
final TextView source;
final String contentDescription;
final ArrayList<ClockRow> rows;
final Bounds bounds;
ClockPlacement(
TextView source,
String contentDescription,
ArrayList<ClockRow> rows,
Bounds bounds
) {
this.source = source;
this.contentDescription = contentDescription;
this.rows = rows;
this.bounds = bounds;
}
}
final class ClockRow {
final CharSequence text;
final int x;
final int y;
final int width;
final int height;
final int clipLeft;
final int rowIndex;
final ClockRowSegment segment;
ClockRow(
CharSequence text,
int x,
int y,
int width,
int height,
int clipLeft,
int rowIndex,
ClockRowSegment segment
) {
this.text = text;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.clipLeft = clipLeft;
this.rowIndex = rowIndex;
this.segment = segment;
}
}
enum ClockRowSegment {
FULL,
LEFT,
RIGHT
}
@@ -0,0 +1,378 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.graphics.Color;
import android.graphics.Canvas;
import android.graphics.Bitmap;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.view.DisplayCutout;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.ImageView;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
public final class DebugBoundsOverlayController {
private static final String TAG_OVERLAY_HOST = "statusbartweak.debug.bounds.host";
private static final String TAG_CLOCK_HOST = "statusbartweak.unlocked.clock.host";
private static final String OVERFLOW_DOT_KEY = "statusbartweak:overflow_dot";
private static final int COLOR_NOTIFICATION = Color.argb(96, 0, 128, 255);
private static final int COLOR_STATUS = Color.argb(96, 255, 64, 64);
private static final int COLOR_CLOCK = Color.argb(96, 64, 220, 96);
private static final int COLOR_CHIP = Color.argb(112, 255, 0, 220);
private static final int COLOR_CUTOUT = Color.argb(112, 255, 180, 0);
public boolean hasEnabledVisualizer(boolean layoutEnabled, SbtSettings settings) {
if (settings == null) {
return false;
}
return settings.debugVisualizeCameraCutout
|| (layoutEnabled
&& (settings.debugVisualizeNotificationContainer
|| settings.debugVisualizeStatusContainer
|| settings.debugVisualizeClockContainer
|| settings.debugVisualizeChipContainer));
}
public void apply(View root, boolean layoutEnabled, SbtSettings settings) {
if (!(root instanceof ViewGroup parent) || settings == null) {
return;
}
if (!hasEnabledVisualizer(layoutEnabled, settings)) {
removeOverlay(parent);
return;
}
ArrayList<OverlaySpec> specs = new ArrayList<>();
if (settings.debugVisualizeCameraCutout) {
addCutoutSpecs(root, specs);
}
if (layoutEnabled && settings.debugVisualizeNotificationContainer) {
Bounds bounds = unionHostChildren(
root,
parent,
OwnedIconHostManager.TAG_UNLOCKED_NOTIFICATION_HOST,
true);
if (bounds != null) {
specs.add(new OverlaySpec(bounds, COLOR_NOTIFICATION));
}
}
if (layoutEnabled && settings.debugVisualizeStatusContainer) {
Bounds bounds = unionHostChildren(
root,
parent,
OwnedIconHostManager.TAG_UNLOCKED_STATUS_HOST,
false);
if (bounds != null) {
specs.add(new OverlaySpec(bounds, COLOR_STATUS));
}
}
if (layoutEnabled && settings.debugVisualizeClockContainer) {
addHostChildSpecs(root, parent, TAG_CLOCK_HOST, COLOR_CLOCK, specs);
}
if (layoutEnabled && settings.debugVisualizeChipContainer) {
Bounds bounds = unionHostChildren(
root,
parent,
OwnedIconHostManager.TAG_UNLOCKED_CHIP_HOST,
false);
if (bounds != null) {
specs.add(new OverlaySpec(bounds, COLOR_CHIP));
}
}
if (specs.isEmpty()) {
removeOverlay(parent);
return;
}
OverlayView host = ensureOverlay(parent);
if (host == null) {
return;
}
int width = Math.max(0, root.getWidth());
int height = Math.max(0, root.getHeight());
if (host.getLeft() != 0
|| host.getTop() != 0
|| host.getRight() != width
|| host.getBottom() != height) {
host.layout(0, 0, width, height);
}
host.setSpecs(specs);
if (parent.getChildAt(parent.getChildCount() - 1) != host) {
host.bringToFront();
}
}
public void remove(View root) {
if (root instanceof ViewGroup parent) {
removeOverlay(parent);
}
}
private void addCutoutSpecs(View root, ArrayList<OverlaySpec> specs) {
WindowInsets insets = root.getRootWindowInsets();
DisplayCutout cutout = insets != null ? insets.getDisplayCutout() : null;
List<Rect> rects = cutout != null ? cutout.getBoundingRects() : null;
if (rects == null || rects.isEmpty()) {
return;
}
for (Rect rect : rects) {
Bounds bounds = clampToRoot(root, rect);
if (bounds != null) {
specs.add(new OverlaySpec(bounds, COLOR_CUTOUT));
}
}
}
private Bounds clampToRoot(View root, Rect rect) {
if (root == null || rect == null || rect.isEmpty()) {
return null;
}
int left = clamp(rect.left, 0, root.getWidth());
int top = clamp(rect.top, 0, root.getHeight());
int right = clamp(rect.right, 0, root.getWidth());
int bottom = clamp(rect.bottom, 0, root.getHeight());
if (right <= left || bottom <= top) {
return null;
}
return new Bounds(left, top, right - left, bottom - top);
}
private Bounds unionHostChildren(
View root,
ViewGroup parent,
String hostTag,
boolean stableRegularIconSlots
) {
View host = findDirectTaggedChild(parent, hostTag);
if (!(host instanceof ViewGroup group)) {
return null;
}
Bounds union = null;
for (int i = 0; i < group.getChildCount(); i++) {
View child = group.getChildAt(i);
if (child == null || child.getVisibility() == View.GONE) {
continue;
}
Bounds bounds = overlayBoundsRelativeTo(root, child, stableRegularIconSlots);
if (bounds == null || bounds.width <= 0 || bounds.height <= 0) {
continue;
}
union = union == null ? bounds : union.union(bounds);
}
return union;
}
private boolean isOverflowDot(View view) {
Object tag = view != null ? view.getTag() : null;
return tag instanceof String string && string.startsWith(OVERFLOW_DOT_KEY);
}
private Bounds overlayBoundsRelativeTo(View root, View child, boolean stableRegularIconSlots) {
if (stableRegularIconSlots && !isOverflowDot(child)) {
return boundsRelativeTo(root, child);
}
Bounds contentBounds = contentBoundsRelativeTo(root, child);
Bounds slotBounds = boundsRelativeTo(root, child);
if (isOverflowDot(child) && contentBounds != null && slotBounds != null) {
return new Bounds(
contentBounds.left,
slotBounds.top,
contentBounds.width,
slotBounds.height);
}
return contentBounds;
}
private void addHostChildSpecs(
View root,
ViewGroup parent,
String hostTag,
int color,
ArrayList<OverlaySpec> specs
) {
View host = findDirectTaggedChild(parent, hostTag);
if (!(host instanceof ViewGroup group) || specs == null) {
return;
}
for (int i = 0; i < group.getChildCount(); i++) {
View child = group.getChildAt(i);
if (child == null || child.getVisibility() == View.GONE) {
continue;
}
Bounds bounds = contentBoundsRelativeTo(root, child);
if (bounds != null && bounds.width > 0 && bounds.height > 0) {
specs.add(new OverlaySpec(bounds, color));
}
}
}
private OverlayView ensureOverlay(ViewGroup parent) {
View existing = findDirectTaggedChild(parent, TAG_OVERLAY_HOST);
if (existing instanceof OverlayView overlayView) {
return overlayView;
}
OverlayView host = new OverlayView(parent.getContext());
host.setTag(TAG_OVERLAY_HOST);
host.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
host.setClickable(false);
host.setFocusable(false);
parent.addView(host, new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
return host;
}
private void removeOverlay(ViewGroup parent) {
View existing = findDirectTaggedChild(parent, TAG_OVERLAY_HOST);
if (existing != null) {
parent.removeView(existing);
}
}
private View findDirectTaggedChild(ViewGroup parent, String tag) {
if (parent == null || tag == null) {
return null;
}
for (int i = 0; i < parent.getChildCount(); i++) {
View child = parent.getChildAt(i);
if (tag.equals(child.getTag())) {
return child;
}
}
return null;
}
private Bounds boundsRelativeTo(View root, View view) {
if (root == null || view == null) {
return null;
}
int left = 0;
int top = 0;
View current = view;
while (current != null && current != root) {
left += current.getLeft() + Math.round(current.getTranslationX());
top += current.getTop() + Math.round(current.getTranslationY());
Object parent = current.getParent();
if (parent instanceof View parentView) {
left -= parentView.getScrollX();
top -= parentView.getScrollY();
}
current = parent instanceof View ? (View) parent : null;
}
if (current != root) {
return null;
}
int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth();
int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight();
return new Bounds(left, top, Math.max(0, width), Math.max(0, height));
}
private Bounds contentBoundsRelativeTo(View root, View view) {
Bounds viewBounds = boundsRelativeTo(root, view);
if (viewBounds == null) {
return null;
}
Bounds bitmapBounds = bitmapContentBounds(view);
if (bitmapBounds == null) {
return viewBounds;
}
return new Bounds(
viewBounds.left + bitmapBounds.left,
viewBounds.top + bitmapBounds.top,
bitmapBounds.width,
bitmapBounds.height);
}
private Bounds bitmapContentBounds(View view) {
if (!(view instanceof ImageView imageView)) {
return null;
}
Drawable drawable = imageView.getDrawable();
if (!(drawable instanceof BitmapDrawable bitmapDrawable)) {
return null;
}
Bitmap bitmap = bitmapDrawable.getBitmap();
if (bitmap == null || bitmap.isRecycled()) {
return null;
}
int width = bitmap.getWidth();
int height = bitmap.getHeight();
int left = width;
int top = height;
int right = -1;
int bottom = -1;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
if (Color.alpha(bitmap.getPixel(x, y)) == 0) {
continue;
}
left = Math.min(left, x);
top = Math.min(top, y);
right = Math.max(right, x);
bottom = Math.max(bottom, y);
}
}
if (right < left || bottom < top) {
return null;
}
return new Bounds(left, top, right - left + 1, bottom - top + 1);
}
private int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
private record OverlaySpec(Bounds bounds, int color) {
}
private record Bounds(int left, int top, int width, int height) {
Bounds union(Bounds other) {
int right = Math.max(left + width, other.left + other.width);
int bottom = Math.max(top + height, other.top + other.height);
int newLeft = Math.min(left, other.left);
int newTop = Math.min(top, other.top);
return new Bounds(newLeft, newTop, right - newLeft, bottom - newTop);
}
}
private static final class OverlayView extends View {
private final Paint paint = new Paint();
private ArrayList<OverlaySpec> specs = new ArrayList<>();
OverlayView(android.content.Context context) {
super(context);
setWillNotDraw(false);
}
void setSpecs(ArrayList<OverlaySpec> specs) {
ArrayList<OverlaySpec> next = specs != null ? new ArrayList<>(specs) : new ArrayList<>();
if (Objects.equals(this.specs, next)) {
return;
}
this.specs = next;
invalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
for (OverlaySpec spec : specs) {
Bounds bounds = spec.bounds;
paint.setColor(spec.color);
canvas.drawRect(
bounds.left,
bounds.top,
bounds.left + bounds.width,
bounds.top + bounds.height,
paint);
}
}
}
}
@@ -0,0 +1,133 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import java.util.Objects;
/**
* Creates and owns runtime icon hosts inside SystemUI roots.
*
* This class only manages host containers. It does not solve layout or rehost
* icon views; those steps stay in the layout and render layers.
*/
public final class OwnedIconHostManager {
public static final String TAG_UNLOCKED_STATUS_HOST =
"statusbartweak.unlocked.status.host";
public static final String TAG_UNLOCKED_NOTIFICATION_HOST =
"statusbartweak.unlocked.notification.host";
public static final String TAG_UNLOCKED_CLOCK_HOST =
"statusbartweak.unlocked.clock.host";
public static final String TAG_UNLOCKED_CHIP_HOST =
"statusbartweak.unlocked.chip.host";
public FrameLayout ensureUnlockedStatusHost(View root) {
return ensureHost(root, TAG_UNLOCKED_STATUS_HOST);
}
public FrameLayout ensureUnlockedNotificationHost(View root) {
return ensureHost(root, TAG_UNLOCKED_NOTIFICATION_HOST);
}
public FrameLayout ensureUnlockedChipHost(View root) {
return ensureHost(root, TAG_UNLOCKED_CHIP_HOST);
}
public FrameLayout ensureUnlockedClockHost(View root) {
return ensureHost(root, TAG_UNLOCKED_CLOCK_HOST);
}
public FrameLayout ensureHost(View root, String tag) {
Objects.requireNonNull(tag, "tag");
if (!(root instanceof ViewGroup parent)) {
return null;
}
FrameLayout existing = findDirectHost(parent, tag);
if (existing != null) {
existing.bringToFront();
return existing;
}
FrameLayout host = new FrameLayout(parent.getContext());
host.setTag(tag);
host.setClipChildren(false);
host.setClipToPadding(false);
host.setClipToOutline(false);
host.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
parent.addView(host, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
disableAncestorClipping(parent);
host.bringToFront();
return host;
}
public void removeHost(View root, String tag) {
if (!(root instanceof ViewGroup parent) || tag == null) {
return;
}
FrameLayout host = findDirectHost(parent, tag);
if (host != null) {
parent.removeView(host);
}
}
public void removeUnlockedHosts(View root) {
removeHost(root, TAG_UNLOCKED_STATUS_HOST);
removeHost(root, TAG_UNLOCKED_NOTIFICATION_HOST);
removeHost(root, TAG_UNLOCKED_CLOCK_HOST);
removeHost(root, TAG_UNLOCKED_CHIP_HOST);
}
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_CHIP_HOST, visible);
}
public boolean isOwnedHost(View view) {
if (view == null) {
return false;
}
Object tag = view.getTag();
return TAG_UNLOCKED_STATUS_HOST.equals(tag)
|| TAG_UNLOCKED_NOTIFICATION_HOST.equals(tag)
|| TAG_UNLOCKED_CLOCK_HOST.equals(tag)
|| TAG_UNLOCKED_CHIP_HOST.equals(tag);
}
private FrameLayout findDirectHost(ViewGroup parent, String tag) {
for (int i = 0; i < parent.getChildCount(); i++) {
View child = parent.getChildAt(i);
if (child instanceof FrameLayout frameLayout && tag.equals(child.getTag())) {
return frameLayout;
}
}
return null;
}
private void setHostVisible(View root, String tag, boolean visible) {
if (!(root instanceof ViewGroup parent) || tag == null) {
return;
}
FrameLayout host = findDirectHost(parent, tag);
if (host == null) {
return;
}
host.setVisibility(visible ? View.VISIBLE : View.INVISIBLE);
}
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++;
}
}
}
@@ -0,0 +1,382 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.graphics.Bitmap;
import android.view.View;
import android.widget.TextView;
import java.util.ArrayList;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds;
record SnapshotSource(
View view,
SnapshotMode mode
) {
}
enum SnapshotMode {
VIEW,
NOTIFICATION_DRAWABLE
}
final class SnapshotKeys {
static final String OVERFLOW_DOT = "statusbartweak:overflow_dot";
static final String EMPTY_CHIP = "statusbartweak:empty_chip";
static final String EMPTY_ICON_CONTAINER = "statusbartweak:empty_icon_container";
private SnapshotKeys() {
}
}
final class SnapshotPlacement {
final SnapshotSource source;
final Bounds bounds;
final String key;
final boolean signal;
final boolean overflowDot;
final HeldSignal heldSignal;
final Bounds contentBounds;
final int sourceOffsetX;
SnapshotPlacement(
SnapshotSource source,
Bounds bounds,
String key,
boolean signal,
boolean overflowDot,
HeldSignal heldSignal,
Bounds contentBounds,
int sourceOffsetX
) {
this.source = source;
this.bounds = bounds;
this.key = key;
this.signal = signal;
this.overflowDot = overflowDot;
this.heldSignal = heldSignal;
this.contentBounds = contentBounds;
this.sourceOffsetX = sourceOffsetX;
}
SnapshotSource source() {
return source;
}
Bounds bounds() {
return bounds;
}
String key() {
return key;
}
boolean signal() {
return signal;
}
boolean overflowDot() {
return overflowDot;
}
HeldSignal heldSignal() {
return heldSignal;
}
Bounds contentBounds() {
return contentBounds;
}
int sourceOffsetX() {
return sourceOffsetX;
}
}
record BitmapResult(Bitmap bitmap, boolean created) {
}
record SnapshotRenderState(
String key,
int left,
int top,
int width,
int height,
boolean signal,
boolean overflowDot,
int sourceOffsetX,
String heldSignalKey,
int heldBitmapId,
SnapshotMode sourceMode,
int sourceSignature
) {
}
final class HeldSignal {
final String key;
final Bounds bounds;
final Bitmap bitmap;
HeldSignal(String key, Bounds bounds, Bitmap bitmap) {
this.key = key;
this.bounds = bounds;
this.bitmap = bitmap;
}
void recycle() {
if (bitmap != null && !bitmap.isRecycled()) {
bitmap.recycle();
}
}
}
final class RootAnchors {
final View clockContainer;
final TextView clockView;
final View statusRoot;
final View statusEndContent;
final View statusIcons;
final View battery;
final View notificationRoot;
RootAnchors(
View clockContainer,
TextView clockView,
View statusRoot,
View statusEndContent,
View statusIcons,
View battery,
View notificationRoot
) {
this.clockContainer = clockContainer;
this.clockView = clockView;
this.statusRoot = statusRoot;
this.statusEndContent = statusEndContent;
this.statusIcons = statusIcons;
this.battery = battery;
this.notificationRoot = notificationRoot;
}
boolean isUsable() {
return isAttached(statusRoot)
&& isAttached(battery)
&& isAttached(notificationRoot)
&& (clockContainer == null || isAttached(clockContainer));
}
private static boolean isAttached(View view) {
return view != null && view.isAttachedToWindow();
}
}
final class CollectedIcons {
final TextView clockView;
final ArrayList<SnapshotSource> statusIcons;
final ArrayList<SnapshotSource> notificationIcons;
final ArrayList<SnapshotSource> statusChips;
final ArrayList<SnapshotSource> statusChipMetrics;
CollectedIcons(
TextView clockView,
ArrayList<SnapshotSource> statusIcons,
ArrayList<SnapshotSource> notificationIcons,
ArrayList<SnapshotSource> statusChips,
ArrayList<SnapshotSource> statusChipMetrics
) {
this.clockView = clockView;
this.statusIcons = statusIcons;
this.notificationIcons = notificationIcons;
this.statusChips = statusChips;
this.statusChipMetrics = statusChipMetrics;
}
}
final class LayoutInputSignature {
final String scene;
final int rootWidth;
final int rootHeight;
final int rootPaddingLeft;
final int rootPaddingRight;
final int cutoutLeft;
final int cutoutTop;
final int cutoutRight;
final int cutoutBottom;
final int settings;
final int clockTime;
final int clockView;
final int clockContainer;
final int statusRoot;
final int statusEndContent;
final int statusIcons;
final int battery;
final int notificationRoot;
final int statusSources;
final int notificationSources;
final int chipSources;
LayoutInputSignature(
String scene,
int rootWidth,
int rootHeight,
int rootPaddingLeft,
int rootPaddingRight,
int cutoutLeft,
int cutoutTop,
int cutoutRight,
int cutoutBottom,
int settings,
int clockTime,
int clockView,
int clockContainer,
int statusRoot,
int statusEndContent,
int statusIcons,
int battery,
int notificationRoot,
int statusSources,
int notificationSources,
int chipSources
) {
this.scene = scene;
this.rootWidth = rootWidth;
this.rootHeight = rootHeight;
this.rootPaddingLeft = rootPaddingLeft;
this.rootPaddingRight = rootPaddingRight;
this.cutoutLeft = cutoutLeft;
this.cutoutTop = cutoutTop;
this.cutoutRight = cutoutRight;
this.cutoutBottom = cutoutBottom;
this.settings = settings;
this.clockTime = clockTime;
this.clockView = clockView;
this.clockContainer = clockContainer;
this.statusRoot = statusRoot;
this.statusEndContent = statusEndContent;
this.statusIcons = statusIcons;
this.battery = battery;
this.notificationRoot = notificationRoot;
this.statusSources = statusSources;
this.notificationSources = notificationSources;
this.chipSources = chipSources;
}
int stockSurfaceSignature() {
return java.util.Objects.hash(
rootWidth,
rootHeight,
rootPaddingLeft,
rootPaddingRight,
settings,
statusRoot,
statusEndContent,
statusIcons,
battery,
notificationRoot,
statusSources,
notificationSources);
}
int nonClockLayoutSignature() {
return java.util.Objects.hash(
scene,
rootWidth,
rootHeight,
rootPaddingLeft,
rootPaddingRight,
cutoutLeft,
cutoutTop,
cutoutRight,
cutoutBottom,
settings,
statusRoot,
statusEndContent,
statusIcons,
battery,
notificationRoot,
statusSources,
notificationSources,
chipSources);
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof LayoutInputSignature that)) {
return false;
}
return rootWidth == that.rootWidth
&& rootHeight == that.rootHeight
&& rootPaddingLeft == that.rootPaddingLeft
&& rootPaddingRight == that.rootPaddingRight
&& cutoutLeft == that.cutoutLeft
&& cutoutTop == that.cutoutTop
&& cutoutRight == that.cutoutRight
&& cutoutBottom == that.cutoutBottom
&& settings == that.settings
&& clockTime == that.clockTime
&& clockView == that.clockView
&& clockContainer == that.clockContainer
&& statusRoot == that.statusRoot
&& statusEndContent == that.statusEndContent
&& statusIcons == that.statusIcons
&& battery == that.battery
&& notificationRoot == that.notificationRoot
&& statusSources == that.statusSources
&& notificationSources == that.notificationSources
&& chipSources == that.chipSources
&& java.util.Objects.equals(scene, that.scene);
}
@Override
public int hashCode() {
return java.util.Objects.hash(
scene,
rootWidth,
rootHeight,
rootPaddingLeft,
rootPaddingRight,
cutoutLeft,
cutoutTop,
cutoutRight,
cutoutBottom,
settings,
clockTime,
clockView,
clockContainer,
statusRoot,
statusEndContent,
statusIcons,
battery,
notificationRoot,
statusSources,
notificationSources,
chipSources);
}
}
final class LayoutPlan {
final ClockPlacement clockPlacement;
final ArrayList<SnapshotPlacement> chipPlacements;
final ArrayList<SnapshotPlacement> statusPlacements;
final ArrayList<SnapshotPlacement> notificationPlacements;
LayoutPlan(
ClockPlacement clockPlacement,
ArrayList<SnapshotPlacement> chipPlacements,
ArrayList<SnapshotPlacement> statusPlacements,
ArrayList<SnapshotPlacement> notificationPlacements
) {
this.clockPlacement = clockPlacement;
this.chipPlacements = chipPlacements;
this.statusPlacements = statusPlacements;
this.notificationPlacements = notificationPlacements;
}
LayoutPlan withClockPlacement(ClockPlacement placement) {
return new LayoutPlan(
placement,
chipPlacements,
statusPlacements,
notificationPlacements);
}
}
@@ -0,0 +1,697 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.SystemClock;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.WeakHashMap;
import se.ajpanton.statusbartweak.runtime.hooks.ReflectionSupport;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds;
final class SnapshotRenderer {
private static final long TRANSITION_SIGNAL_HOLD_MS = 700L;
private final boolean holdSignalsDuringTransition;
private final boolean sortPlacementsByX;
private final boolean trimViewContentForCollision;
private final WeakHashMap<ViewGroup, LinkedHashMap<String, ImageView>> proxiesByHost =
new WeakHashMap<>();
private final WeakHashMap<ImageView, SnapshotRenderState> statesByProxy =
new WeakHashMap<>();
private final LinkedHashMap<String, HeldSignal> heldSignals = new LinkedHashMap<>();
private long holdSignalsUntilElapsedMs;
SnapshotRenderer(
boolean holdSignalsDuringTransition,
boolean sortPlacementsByX,
boolean trimViewContentForCollision
) {
this.holdSignalsDuringTransition = holdSignalsDuringTransition;
this.sortPlacementsByX = sortPlacementsByX;
this.trimViewContentForCollision = trimViewContentForCollision;
}
void beginTransitionHold() {
if (holdSignalsDuringTransition) {
holdSignalsUntilElapsedMs = SystemClock.elapsedRealtime() + TRANSITION_SIGNAL_HOLD_MS;
}
}
ArrayList<SnapshotPlacement> rawPlacements(
View root,
ArrayList<SnapshotSource> sources
) {
ArrayList<SnapshotPlacement> placements = new ArrayList<>();
int order = 0;
for (SnapshotSource source : sources) {
Bounds bounds = visualBoundsRelativeTo(root, source.view());
if (bounds != null && bounds.width > 0 && bounds.height > 0) {
if (source.mode() == SnapshotMode.NOTIFICATION_DRAWABLE) {
bounds = notificationDrawableBounds(bounds);
}
String key = normalizedKeyFor(source, order);
boolean signal = isSignalKey(key);
boolean trimContent = !signal && shouldTrimContent(source);
Bounds contentBounds = trimContent ? snapshotContentBounds(source, bounds) : null;
if (trimContent && contentBounds == null) {
continue;
}
HeldSignal heldSignal = heldSignalForTransition(key);
if (signal
&& heldSignal != null
&& !isSignalSettled(source.view(), key, bounds)) {
placements.add(new SnapshotPlacement(
null,
heldBoundsForCurrentRow(heldSignal, bounds),
key,
true,
false,
heldSignal,
null,
0));
} else {
Bounds placementBounds = signal
? normalizedStatusBounds(new SnapshotPlacement(
source,
bounds,
key,
true,
false,
null,
null,
0))
: bounds;
placements.add(new SnapshotPlacement(
source,
placementBounds,
key,
signal,
false,
null,
contentBounds,
0));
}
}
order++;
}
if (sortPlacementsByX) {
placements.sort(Comparator.comparingInt(placement -> placement.bounds().left));
}
return placements;
}
Bounds snapshotContentBounds(SnapshotSource source, Bounds bounds) {
if (source == null || bounds == null || bounds.width <= 0 || bounds.height <= 0) {
return null;
}
Bitmap bitmap = Bitmap.createBitmap(bounds.width, bounds.height, Bitmap.Config.ARGB_8888);
try {
drawSnapshot(source, bitmap);
return bitmapContentBounds(bitmap);
} finally {
bitmap.recycle();
}
}
Bounds renderPlacements(
ViewGroup host,
ArrayList<SnapshotPlacement> placements
) {
if (placements == null || placements.isEmpty()) {
clear(host);
return null;
}
LinkedHashMap<String, ImageView> proxies = proxiesFor(host);
HashSet<String> desiredKeys = new HashSet<>();
boolean structureChanged = false;
Bounds renderedBounds = null;
for (SnapshotPlacement placement : placements) {
Bounds bounds = placement.bounds();
if (bounds == null || bounds.width <= 0 || bounds.height <= 0) {
continue;
}
String key = placement.key();
desiredKeys.add(key);
ImageView proxy = proxies.get(key);
boolean proxyChanged = false;
if (proxy == null) {
proxy = new ImageView(host.getContext());
proxy.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
proxies.put(key, proxy);
host.addView(proxy);
structureChanged = true;
proxyChanged = true;
} else if (proxy.getParent() != host) {
detachFromParent(proxy);
host.addView(proxy);
structureChanged = true;
proxyChanged = true;
}
if (!java.util.Objects.equals(proxy.getTag(), key)) {
proxy.setTag(key);
proxyChanged = true;
}
BitmapResult bitmapResult = bitmapFor(proxy, bounds);
Bitmap bitmap = bitmapResult.bitmap();
SnapshotRenderState desiredState = renderStateFor(placement);
boolean bitmapChanged = bitmapResult.created()
|| !desiredState.equals(statesByProxy.get(proxy));
if (bitmapChanged) {
if (placement.overflowDot()) {
drawOverflowDot(bitmap);
} else if (placement.heldSignal() != null) {
drawHeldSignal(placement.heldSignal(), bitmap);
} else if (placement.source() != null) {
drawSnapshot(placement.source(), bitmap, placement.sourceOffsetX());
if (placement.signal()) {
saveHeldSignal(key, bounds, bitmap);
}
} else {
bitmap.eraseColor(Color.TRANSPARENT);
}
statesByProxy.put(proxy, desiredState);
proxyChanged = true;
}
if (proxy.getDrawable() == null) {
proxy.setImageBitmap(bitmap);
proxyChanged = true;
}
if (proxy.getAlpha() != 1f) {
proxy.setAlpha(1f);
proxyChanged = true;
}
if (proxy.getVisibility() != View.VISIBLE) {
proxy.setVisibility(View.VISIBLE);
proxyChanged = true;
}
if (applyLayoutParamsIfChanged(host, proxy, bounds)) {
structureChanged = true;
proxyChanged = true;
}
if (proxy.getLeft() != bounds.left
|| proxy.getTop() != bounds.top
|| proxy.getRight() != bounds.left + bounds.width
|| proxy.getBottom() != bounds.top + bounds.height) {
proxy.layout(bounds.left, bounds.top, bounds.left + bounds.width, bounds.top + bounds.height);
proxyChanged = true;
}
if (proxyChanged) {
proxy.invalidate();
}
renderedBounds = renderedBounds == null ? bounds : renderedBounds.union(bounds);
}
int removedMissing = removeMissing(proxies, desiredKeys);
if (removedMissing > 0) {
structureChanged = true;
}
pruneHeldSignals(placements);
if (structureChanged) {
host.requestLayout();
host.invalidate();
}
return renderedBounds;
}
void clear(ViewGroup host) {
LinkedHashMap<String, ImageView> proxies = proxiesByHost.remove(host);
if (proxies == null) {
return;
}
for (ImageView proxy : proxies.values()) {
detachFromParent(proxy);
statesByProxy.remove(proxy);
}
host.requestLayout();
host.invalidate();
}
void clearAll() {
for (ViewGroup host : new ArrayList<>(proxiesByHost.keySet())) {
clear(host);
}
}
void clearHeldSignals() {
for (HeldSignal heldSignal : heldSignals.values()) {
heldSignal.recycle();
}
heldSignals.clear();
holdSignalsUntilElapsedMs = 0L;
}
private Bounds notificationDrawableBounds(Bounds sourceBounds) {
int size = Math.max(1, Math.min(sourceBounds.width, sourceBounds.height));
int left = sourceBounds.left + Math.max(0, (sourceBounds.width - size) / 2);
int top = sourceBounds.top + Math.max(0, (sourceBounds.height - size) / 2);
return new Bounds(left, top, size, size);
}
private boolean shouldTrimContent(SnapshotSource source) {
return trimViewContentForCollision && source != null && source.mode() == SnapshotMode.VIEW;
}
private Bounds bitmapContentBounds(Bitmap bitmap) {
if (bitmap == null || bitmap.isRecycled()) {
return null;
}
int width = bitmap.getWidth();
int height = bitmap.getHeight();
int left = width;
int top = height;
int right = -1;
int bottom = -1;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
if (Color.alpha(bitmap.getPixel(x, y)) == 0) {
continue;
}
left = Math.min(left, x);
top = Math.min(top, y);
right = Math.max(right, x);
bottom = Math.max(bottom, y);
}
}
if (right < left || bottom < top) {
return null;
}
return new Bounds(left, top, right - left + 1, bottom - top + 1);
}
private Bounds heldBoundsForCurrentRow(HeldSignal heldSignal, Bounds currentBounds) {
int height = Math.max(1, currentBounds.height);
int width = heldSignal.bounds.width;
if (heldSignal.bounds.height > 0) {
width = Math.round((float) heldSignal.bounds.width * height / heldSignal.bounds.height);
}
return new Bounds(currentBounds.left, currentBounds.top, Math.max(1, width), height);
}
private Bounds normalizedStatusBounds(SnapshotPlacement placement) {
Bounds bounds = placement.bounds();
int stableWidth = bounds.width;
if (placement.signal()) {
stableWidth = Math.min(stableWidth, stableSignalWidth(placement.key(), bounds.height));
}
return new Bounds(bounds.left, bounds.top, Math.max(1, stableWidth), bounds.height);
}
private int stableSignalWidth(String key, int height) {
if (key.contains("wifi")) {
return Math.max(1, Math.round(height * 1.25f));
}
if (key.contains("mobile")) {
return Integer.MAX_VALUE;
}
return Math.max(1, height);
}
private LinkedHashMap<String, ImageView> proxiesFor(ViewGroup host) {
LinkedHashMap<String, ImageView> proxies = proxiesByHost.get(host);
if (proxies != null) {
return proxies;
}
LinkedHashMap<String, ImageView> created = new LinkedHashMap<>();
proxiesByHost.put(host, created);
return created;
}
private String normalizedKeyFor(SnapshotSource source, int order) {
String key = keyFor(source, order);
String kind = signalKind(source.view(), key);
return kind != null ? "signal:" + kind : key;
}
private String keyFor(SnapshotSource source, int order) {
View view = source.view();
String slot = reflectString(view, "getSlot", "mSlot", "slot");
if (!slot.isEmpty()) {
return slot;
}
String key = reflectString(view, "getNotificationKey", "mNotificationKey", "notificationKey", "mKey", "key");
if (!key.isEmpty()) {
return key;
}
return view.getClass().getName() + "#" + order;
}
private String signalKind(View view, String key) {
String lowerKey = key.toLowerCase(java.util.Locale.ROOT);
if (lowerKey.contains("wifi")) {
return "wifi";
}
if (lowerKey.contains("mobile")) {
return "mobile";
}
String idName = resolveIdName(view).toLowerCase(java.util.Locale.ROOT);
String className = view.getClass().getName().toLowerCase(java.util.Locale.ROOT);
if (idName.contains("wifi") || className.contains("wifi")) {
return "wifi";
}
if (idName.contains("mobile") || className.contains("mobile")) {
return "mobile";
}
return null;
}
private boolean isSignalKey(String key) {
return key.startsWith("signal:");
}
private HeldSignal heldSignalForTransition(String key) {
return isTransitionHoldActive() ? heldSignals.get(key) : null;
}
private boolean isTransitionHoldActive() {
return holdSignalsDuringTransition
&& SystemClock.elapsedRealtime() <= holdSignalsUntilElapsedMs;
}
private boolean isSignalSettled(View source, String key, Bounds bounds) {
if (source.getAlpha() < 0.1f) {
return false;
}
return bounds.width <= stableSignalWidth(key, bounds.height) + 2;
}
private void saveHeldSignal(String key, Bounds bounds, Bitmap bitmap) {
if (!holdSignalsDuringTransition || !isSignalKey(key)) {
return;
}
Bitmap copy = bitmap.copy(Bitmap.Config.ARGB_8888, false);
HeldSignal previous = heldSignals.put(key, new HeldSignal(key, bounds, copy));
if (previous != null) {
previous.recycle();
}
}
private void pruneHeldSignals(ArrayList<SnapshotPlacement> placements) {
if (!holdSignalsDuringTransition || isTransitionHoldActive() || heldSignals.isEmpty()) {
return;
}
HashSet<String> liveSignalKeys = new HashSet<>();
for (SnapshotPlacement placement : placements) {
if (placement.signal() && placement.source() != null) {
liveSignalKeys.add(placement.key());
}
}
ArrayList<String> staleKeys = new ArrayList<>();
for (String key : heldSignals.keySet()) {
if (!liveSignalKeys.contains(key)) {
staleKeys.add(key);
}
}
for (String key : staleKeys) {
HeldSignal stale = heldSignals.remove(key);
if (stale != null) {
stale.recycle();
}
}
}
private int removeMissing(
LinkedHashMap<String, ImageView> proxies,
HashSet<String> desiredKeys
) {
ArrayList<String> staleKeys = new ArrayList<>();
for (Map.Entry<String, ImageView> entry : proxies.entrySet()) {
if (!desiredKeys.contains(entry.getKey())) {
ImageView proxy = entry.getValue();
detachFromParent(proxy);
statesByProxy.remove(proxy);
staleKeys.add(entry.getKey());
}
}
for (String key : staleKeys) {
proxies.remove(key);
}
return staleKeys.size();
}
private BitmapResult bitmapFor(ImageView proxy, Bounds bounds) {
Drawable drawable = proxy.getDrawable();
if (drawable instanceof BitmapDrawable bitmapDrawable) {
Bitmap bitmap = bitmapDrawable.getBitmap();
if (bitmap != null
&& !bitmap.isRecycled()
&& bitmap.getWidth() == bounds.width
&& bitmap.getHeight() == bounds.height) {
return new BitmapResult(bitmap, false);
}
}
Bitmap bitmap = Bitmap.createBitmap(bounds.width, bounds.height, Bitmap.Config.ARGB_8888);
proxy.setImageBitmap(bitmap);
return new BitmapResult(bitmap, true);
}
private SnapshotRenderState renderStateFor(SnapshotPlacement placement) {
Bounds bounds = placement.bounds();
return new SnapshotRenderState(
placement.key(),
bounds.left,
bounds.top,
bounds.width,
bounds.height,
placement.signal(),
placement.overflowDot(),
placement.sourceOffsetX(),
placement.heldSignal() != null ? placement.heldSignal().key : "",
placement.heldSignal() != null ? System.identityHashCode(placement.heldSignal().bitmap) : 0,
placement.source() != null ? placement.source().mode() : null,
placement.source() != null ? sourceTreeSignature(placement.source().view()) : 0);
}
private int sourceTreeSignature(View view) {
if (view == null) {
return 0;
}
int result = 17;
result = 31 * result + System.identityHashCode(view);
result = 31 * result + view.getVisibility();
result = 31 * result + Float.floatToIntBits(view.getAlpha());
result = 31 * result + view.getWidth();
result = 31 * result + view.getHeight();
result = 31 * result + view.getScrollX();
result = 31 * result + view.getScrollY();
CharSequence contentDescription = view.getContentDescription();
result = 31 * result + (contentDescription != null ? contentDescription.toString().hashCode() : 0);
result = 31 * result + java.util.Arrays.hashCode(view.getDrawableState());
if (view instanceof TextView textView) {
CharSequence text = textView.getText();
result = 31 * result + (text != null ? text.toString().hashCode() : 0);
result = 31 * result + textView.getCurrentTextColor();
result = 31 * result + Float.floatToIntBits(textView.getTextSize());
}
if (view instanceof ImageView imageView) {
result = 31 * result + drawableSignature(imageView.getDrawable());
}
if (view instanceof ViewGroup group) {
result = 31 * result + group.getChildCount();
for (int i = 0; i < group.getChildCount(); i++) {
result = 31 * result + sourceTreeSignature(group.getChildAt(i));
}
}
return result;
}
private int drawableSignature(Drawable drawable) {
if (drawable == null) {
return 0;
}
int result = 17;
result = 31 * result + System.identityHashCode(drawable);
result = 31 * result + drawable.getLevel();
result = 31 * result + drawable.getAlpha();
result = 31 * result + java.util.Arrays.hashCode(drawable.getState());
Rect bounds = drawable.getBounds();
result = 31 * result + bounds.left;
result = 31 * result + bounds.top;
result = 31 * result + bounds.right;
result = 31 * result + bounds.bottom;
return result;
}
private void drawSnapshot(SnapshotSource source, Bitmap bitmap) {
drawSnapshot(source, bitmap, 0);
}
private void drawSnapshot(SnapshotSource source, Bitmap bitmap, int offsetX) {
bitmap.eraseColor(Color.TRANSPARENT);
Canvas canvas = new Canvas(bitmap);
if (offsetX != 0) {
canvas.translate(-offsetX, 0);
}
View sourceView = source.view();
int oldVisibility = sourceView.getVisibility();
float oldAlpha = sourceView.getAlpha();
try {
if (source.mode() == SnapshotMode.NOTIFICATION_DRAWABLE
&& drawNotificationDrawable(
sourceView,
canvas,
bitmap.getWidth(),
bitmap.getHeight())) {
return;
}
sourceView.setVisibility(View.VISIBLE);
sourceView.setAlpha(1f);
sourceView.draw(canvas);
} finally {
sourceView.setAlpha(oldAlpha);
sourceView.setVisibility(oldVisibility);
}
}
private void drawHeldSignal(HeldSignal heldSignal, Bitmap bitmap) {
bitmap.eraseColor(Color.TRANSPARENT);
if (heldSignal.bitmap == null || heldSignal.bitmap.isRecycled()) {
return;
}
Canvas canvas = new Canvas(bitmap);
Rect src = new Rect(0, 0, heldSignal.bitmap.getWidth(), heldSignal.bitmap.getHeight());
Rect dst = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight());
canvas.drawBitmap(heldSignal.bitmap, src, dst, null);
}
private void drawOverflowDot(Bitmap bitmap) {
bitmap.eraseColor(Color.TRANSPARENT);
int base = Math.min(bitmap.getWidth(), bitmap.getHeight());
if (base <= 0) {
return;
}
float radius = Math.max(1f, base * 0.16f);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(Color.WHITE);
Canvas canvas = new Canvas(bitmap);
canvas.drawCircle(bitmap.getWidth() / 2f, bitmap.getHeight() / 2f, radius, paint);
}
private boolean drawNotificationDrawable(View source, Canvas canvas, int width, int height) {
Drawable drawable = findDrawable(source);
if (drawable == null || width <= 0 || height <= 0) {
return false;
}
Rect oldBounds = new Rect(drawable.getBounds());
int size = Math.min(width, height);
if (size <= 0) {
size = Math.max(width, height);
}
int left = Math.max(0, (width - size) / 2);
int top = Math.max(0, (height - size) / 2);
drawable.setBounds(left, top, left + size, top + size);
drawable.draw(canvas);
drawable.setBounds(oldBounds);
return true;
}
private Drawable findDrawable(View source) {
if (source instanceof ImageView imageView && imageView.getDrawable() != null) {
return imageView.getDrawable();
}
if (source instanceof ViewGroup group) {
for (int i = 0; i < group.getChildCount(); i++) {
Drawable drawable = findDrawable(group.getChildAt(i));
if (drawable != null) {
return drawable;
}
}
}
return null;
}
private ViewGroup.LayoutParams layoutParamsFor(ViewGroup host, Bounds bounds) {
if (host instanceof FrameLayout) {
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(bounds.width, bounds.height);
lp.leftMargin = bounds.left;
lp.topMargin = bounds.top;
return lp;
}
ViewGroup.MarginLayoutParams lp = new ViewGroup.MarginLayoutParams(bounds.width, bounds.height);
lp.leftMargin = bounds.left;
lp.topMargin = bounds.top;
return lp;
}
private boolean applyLayoutParamsIfChanged(ViewGroup host, ImageView proxy, Bounds bounds) {
ViewGroup.LayoutParams current = proxy.getLayoutParams();
if (current != null
&& current.width == bounds.width
&& current.height == bounds.height
&& current instanceof ViewGroup.MarginLayoutParams marginParams
&& marginParams.leftMargin == bounds.left
&& marginParams.topMargin == bounds.top) {
return false;
}
proxy.setLayoutParams(layoutParamsFor(host, bounds));
return true;
}
private Bounds visualBoundsRelativeTo(View root, View view) {
if (view == null) {
return null;
}
int left = 0;
int top = 0;
View current = view;
while (current != null && current != root) {
left += current.getLeft() - current.getScrollX() + Math.round(current.getTranslationX());
top += current.getTop() - current.getScrollY() + Math.round(current.getTranslationY());
Object parent = current.getParent();
current = parent instanceof View ? (View) parent : null;
}
if (current != root) {
return null;
}
int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth();
int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight();
return new Bounds(left, top, Math.max(0, width), Math.max(0, height));
}
private static String resolveIdName(View view) {
if (view.getId() == View.NO_ID) {
return "";
}
try {
return view.getResources().getResourceEntryName(view.getId());
} catch (Resources.NotFoundException ignored) {
return "";
}
}
private static String reflectString(Object target, String methodName, String... fieldNames) {
Object methodValue = ReflectionSupport.invokeMethod(target, methodName, new Class<?>[0]);
if (methodValue instanceof String string && !string.isEmpty()) {
return string;
}
for (String fieldName : fieldNames) {
Object fieldValue = ReflectionSupport.getFieldValue(target, fieldName);
if (fieldValue instanceof String string && !string.isEmpty()) {
return string;
}
}
return "";
}
private static void detachFromParent(View view) {
if (view != null && view.getParent() instanceof ViewGroup parent) {
parent.removeView(view);
}
}
}
@@ -0,0 +1,240 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.view.View;
import java.util.ArrayList;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
final class UnlockedChipPlacementController {
private final SnapshotRenderer renderer;
private final UnlockedVerticalOffsetScaler offsetScaler;
private ChipHoldState heldPlacements;
private Bounds lastCollisionBounds;
private int lastRootWidth;
private int lastRootHeight;
UnlockedChipPlacementController(SnapshotRenderer renderer, UnlockedVerticalOffsetScaler offsetScaler) {
this.renderer = renderer;
this.offsetScaler = offsetScaler;
}
void clear() {
heldPlacements = null;
lastCollisionBounds = null;
lastRootWidth = 0;
lastRootHeight = 0;
}
ArrayList<SnapshotPlacement> placements(
View root,
ArrayList<SnapshotSource> visibleSources,
ArrayList<SnapshotSource> metricSources,
SbtSettings settings
) {
ArrayList<SnapshotPlacement> rawPlacements = renderer.rawPlacements(root, visibleSources);
if (!rawPlacements.isEmpty()) {
rememberMetrics(root, rawPlacements);
ArrayList<SnapshotPlacement> placements = offsetPlacements(
rawPlacements,
offsetScaler.chip(
settings.layoutChipVerticalOffsetPx,
representativeChipHeight(rawPlacements)));
heldPlacements = new ChipHoldState(
copyAsHeldPlacements(placements),
root.getWidth(),
root.getHeight(),
0);
return placements;
}
ArrayList<SnapshotPlacement> placements = isAnyChipHidingEnabled(settings)
? new ArrayList<>()
: heldPlacements(root);
if (placements.isEmpty()) {
placements = emptyPlacement(root, metricSources, settings);
}
return placements;
}
private ArrayList<SnapshotPlacement> heldPlacements(View root) {
ChipHoldState state = heldPlacements;
if (state == null || state.placements.isEmpty()) {
return new ArrayList<>();
}
if (state.rootWidth != root.getWidth() || state.rootHeight != root.getHeight()) {
return new ArrayList<>();
}
if (state.missingFrames > 0) {
heldPlacements = null;
return new ArrayList<>();
}
state = new ChipHoldState(
state.placements,
state.rootWidth,
state.rootHeight,
state.missingFrames + 1);
heldPlacements = state;
return new ArrayList<>(state.placements);
}
private ArrayList<SnapshotPlacement> emptyPlacement(
View root,
ArrayList<SnapshotSource> sources,
SbtSettings settings
) {
ArrayList<SnapshotPlacement> result = new ArrayList<>();
Bounds bounds = emptyBounds(root, sources);
if (bounds == null || bounds.height <= 0) {
return result;
}
int verticalOffset = offsetScaler.chip(
settings != null ? settings.layoutChipVerticalOffsetPx : 0,
bounds.height);
result.add(new SnapshotPlacement(
null,
new Bounds(0, bounds.top - verticalOffset, 1, bounds.height),
SnapshotKeys.EMPTY_CHIP,
false,
false,
null,
new Bounds(0, 0, 0, bounds.height),
0));
return result;
}
private Bounds emptyBounds(View root, ArrayList<SnapshotSource> sources) {
if (root != null
&& lastCollisionBounds != null
&& lastRootWidth == root.getWidth()
&& lastRootHeight == root.getHeight()) {
return new Bounds(0, lastCollisionBounds.top, 0, lastCollisionBounds.height);
}
return metricBoundsFromSource(root, sources);
}
private Bounds metricBoundsFromSource(View root, ArrayList<SnapshotSource> sources) {
if (root == null || sources == null || sources.isEmpty()) {
return null;
}
for (SnapshotSource source : sources) {
Bounds bounds = ViewGeometry.boundsRelativeTo(root, source.view());
if (bounds == null || bounds.width <= 0 || bounds.height <= 0) {
continue;
}
Bounds content = renderer.snapshotContentBounds(source, bounds);
if (content != null && content.height > 0) {
return new Bounds(0, bounds.top + content.top, 0, content.height);
}
}
return null;
}
private void rememberMetrics(View root, ArrayList<SnapshotPlacement> placements) {
Bounds bounds = collisionUnion(placements);
if (root != null && bounds != null && bounds.height > 0) {
lastCollisionBounds = bounds;
lastRootWidth = root.getWidth();
lastRootHeight = root.getHeight();
}
}
private Bounds collisionUnion(ArrayList<SnapshotPlacement> placements) {
Bounds union = null;
if (placements == null) {
return null;
}
for (SnapshotPlacement placement : placements) {
Bounds collision = collisionBounds(placement);
if (collision == null || collision.height <= 0) {
continue;
}
union = union == null ? collision : union.union(collision);
}
return union;
}
private Bounds collisionBounds(SnapshotPlacement placement) {
if (placement == null || placement.bounds == null) {
return null;
}
Bounds relative = placement.contentBounds;
if (relative == null || relative.height <= 0 || relative.width < 0) {
relative = new Bounds(0, 0, placement.bounds.width, placement.bounds.height);
}
return new Bounds(
placement.bounds.left + relative.left,
placement.bounds.top + relative.top,
relative.width,
relative.height);
}
private boolean isAnyChipHidingEnabled(SbtSettings settings) {
return settings != null
&& (settings.statusChipsHideAll
|| settings.statusChipsHideMediaUnlocked
|| settings.statusChipsHideNavUnlocked
|| settings.statusChipsHideCallUnlocked);
}
private ArrayList<SnapshotPlacement> copyAsHeldPlacements(ArrayList<SnapshotPlacement> placements) {
ArrayList<SnapshotPlacement> held = new ArrayList<>();
if (placements == null) {
return held;
}
for (SnapshotPlacement placement : placements) {
held.add(new SnapshotPlacement(
null,
placement.bounds,
placement.key,
false,
false,
null,
placement.contentBounds,
placement.sourceOffsetX));
}
return held;
}
private ArrayList<SnapshotPlacement> offsetPlacements(ArrayList<SnapshotPlacement> placements, int verticalOffsetPx) {
if (placements == null || placements.isEmpty() || verticalOffsetPx == 0) {
return placements;
}
ArrayList<SnapshotPlacement> result = new ArrayList<>();
int dy = -verticalOffsetPx;
for (SnapshotPlacement placement : placements) {
Bounds bounds = placement.bounds;
result.add(new SnapshotPlacement(
placement.source,
new Bounds(bounds.left, bounds.top + dy, bounds.width, bounds.height),
placement.key,
placement.signal,
placement.overflowDot,
placement.heldSignal,
placement.contentBounds,
placement.sourceOffsetX));
}
return result;
}
private int representativeChipHeight(ArrayList<SnapshotPlacement> placements) {
Bounds bounds = collisionUnion(placements);
return bounds != null ? Math.max(0, bounds.height) : representativeHeight(placements);
}
private int representativeHeight(ArrayList<SnapshotPlacement> placements) {
if (placements == null || placements.isEmpty()) {
return 0;
}
return Math.max(0, placements.get(0).bounds.height);
}
private record ChipHoldState(
ArrayList<SnapshotPlacement> placements,
int rootWidth,
int rootHeight,
int missingFrames
) {
}
}
@@ -0,0 +1,545 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.WeakHashMap;
import se.ajpanton.statusbartweak.runtime.features.clock.ClockLayoutTextFactory;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds;
import se.ajpanton.statusbartweak.runtime.mode.SceneKey;
import se.ajpanton.statusbartweak.runtime.settings.RuntimeSettingsCache;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
/**
* First unlocked renderer that does not mutate stock icon views or drawables.
*
* It snapshots visible stock icon views into owned ImageViews, then packs them
* into owned unlocked rows instead of copying each stock X position.
*/
public final class UnlockedIconSnapshotRenderController {
private final SnapshotRenderer statusRenderer;
private final SnapshotRenderer notificationRenderer;
private final SnapshotRenderer chipRenderer;
private final ClockProxyRenderer clockRenderer = new ClockProxyRenderer();
private final UnlockedStockSourceCollector sourceCollector = new UnlockedStockSourceCollector();
private final UnlockedVerticalOffsetScaler offsetScaler = new UnlockedVerticalOffsetScaler();
private final UnlockedChipPlacementController chipPlacementController;
private final UnlockedLayoutPlanner layoutPlanner;
private final WeakHashMap<View, Integer> clockBaselineDeltaByRoot = new WeakHashMap<>();
private final WeakHashMap<View, LayoutInputSignature> lastLayoutSignatureByRoot = new WeakHashMap<>();
private final WeakHashMap<View, CollectedIcons> lastCollectedIconsByRoot = new WeakHashMap<>();
private final WeakHashMap<View, LayoutPlan> lastLayoutPlanByRoot = new WeakHashMap<>();
private final WeakHashMap<View, Integer> lastClockGeometryByRoot = new WeakHashMap<>();
private final WeakHashMap<View, Integer> lastClockSourceGeometryByRoot = new WeakHashMap<>();
public UnlockedIconSnapshotRenderController() {
statusRenderer = new SnapshotRenderer(true, true, false);
notificationRenderer = new SnapshotRenderer(false, false, false);
chipRenderer = new SnapshotRenderer(false, false, true);
chipPlacementController = new UnlockedChipPlacementController(chipRenderer, offsetScaler);
layoutPlanner = new UnlockedLayoutPlanner(
statusRenderer,
notificationRenderer,
chipRenderer,
chipPlacementController,
offsetScaler);
}
public RenderResult renderUnlocked(
View root,
ViewGroup clockHost,
ViewGroup chipHost,
ViewGroup statusHost,
ViewGroup notificationHost,
SceneKey scene
) {
if (scene == null || !scene.isUnlocked() || !root.isAttachedToWindow()) {
clearAll();
return new RenderResult(true, true);
}
SbtSettings settings = RuntimeSettingsCache.get(root.getContext());
layoutHostToRoot(root, clockHost);
layoutHostToRoot(root, chipHost);
layoutHostToRoot(root, statusHost);
layoutHostToRoot(root, notificationHost);
RootAnchors anchors = sourceCollector.anchorsFor(root);
CollectedIcons previousIcons = lastCollectedIconsByRoot.get(root);
LayoutInputSignature signature = sourceCollector.layoutInputSignature(
root,
settings,
scene,
ViewGeometry.middleCutout(root),
anchors,
previousIcons);
LayoutInputSignature previousSignature = lastLayoutSignatureByRoot.get(root);
boolean stockSourcesChanged = previousSignature == null
|| signature.stockSurfaceSignature() != previousSignature.stockSurfaceSignature();
boolean chipSourcesChanged = previousSignature == null
|| signature.chipSources != previousSignature.chipSources;
if (signature.equals(previousSignature)) {
return new RenderResult(false, false);
}
lastLayoutSignatureByRoot.put(root, signature);
Integer currentClockSourceGeometry = clockSourceGeometrySignature(
anchors != null ? anchors.clockView : null);
ClockLayout currentClockLayout = null;
Integer currentClockGeometry = null;
if (isClockOnlySignatureChange(previousSignature, signature)
&& settings.clockEnabledUnlocked
&& previousIcons != null) {
LayoutPlan previousPlan = lastLayoutPlanByRoot.get(root);
if (previousPlan != null
&& java.util.Objects.equals(
lastClockSourceGeometryByRoot.get(root),
currentClockSourceGeometry)) {
ClockPlacement updatedClock = clockPlacementWithTextFromModel(
previousPlan.clockPlacement,
anchors != null ? anchors.clockView : null,
settings);
if (updatedClock != null) {
LayoutPlan updatedPlan = previousPlan.withClockPlacement(updatedClock);
lastLayoutPlanByRoot.put(root, updatedPlan);
lastClockSourceGeometryByRoot.put(root, currentClockSourceGeometry);
clockRenderer.render(clockHost, updatedClock);
return new RenderResult(false, false);
}
}
currentClockLayout = buildClockLayout(root, anchors, settings);
currentClockGeometry = clockGeometrySignature(
root,
currentClockLayout,
settings,
UnlockedLayoutPlanner.cutoutBounds(ViewGeometry.middleCutout(root), settings.clockMiddleCutoutGapPx));
ClockPlacement textPlacement = clockTextPlacementForPrevious(
root,
currentClockLayout,
settings,
UnlockedLayoutPlanner.cutoutBounds(ViewGeometry.middleCutout(root), settings.clockMiddleCutoutGapPx),
previousPlan != null ? previousPlan.clockPlacement : null);
ClockPlacement updatedClock = clockPlacementWithUpdatedText(
previousPlan != null ? previousPlan.clockPlacement : null,
textPlacement);
Integer previousClockGeometry = lastClockGeometryByRoot.get(root);
String missReason = clockFastPathMissReason(
previousPlan,
previousClockGeometry,
currentClockGeometry,
updatedClock);
if (missReason == null) {
LayoutPlan updatedPlan = previousPlan.withClockPlacement(updatedClock);
lastLayoutPlanByRoot.put(root, updatedPlan);
clockRenderer.render(clockHost, updatedClock);
return new RenderResult(false, false);
}
}
CollectedIcons collectedIcons = previousIcons;
if (stockSourcesChanged || collectedIcons == null) {
collectedIcons = sourceCollector.collect(root, anchors);
lastCollectedIconsByRoot.put(root, collectedIcons);
} else if (chipSourcesChanged) {
collectedIcons = sourceCollector.collectChips(root, anchors, collectedIcons);
lastCollectedIconsByRoot.put(root, collectedIcons);
}
if (currentClockLayout == null && settings.clockEnabledUnlocked) {
currentClockLayout = buildClockLayout(root, anchors, settings);
currentClockGeometry = clockGeometrySignature(
root,
currentClockLayout,
settings,
UnlockedLayoutPlanner.cutoutBounds(ViewGeometry.middleCutout(root), settings.clockMiddleCutoutGapPx));
}
LayoutPlan layoutPlan = layoutPlanner.solve(
root,
clockHost,
chipHost,
statusHost,
notificationHost,
anchors,
collectedIcons,
settings,
currentClockLayout);
lastLayoutPlanByRoot.put(root, layoutPlan);
lastClockGeometryByRoot.put(root, currentClockGeometry != null ? currentClockGeometry : 0);
lastClockSourceGeometryByRoot.put(root, currentClockSourceGeometry);
if (settings.clockEnabledUnlocked && layoutPlan.clockPlacement != null) {
clockRenderer.render(clockHost, layoutPlan.clockPlacement);
} else {
clockRenderer.clear(clockHost);
}
if (settings.layoutChipEnabledUnlocked && layoutPlan.chipPlacements != null) {
chipRenderer.renderPlacements(chipHost, layoutPlan.chipPlacements);
} else {
chipRenderer.clear(chipHost);
}
if (settings.layoutStatusEnabledUnlocked && layoutPlan.statusPlacements != null) {
statusRenderer.renderPlacements(statusHost, layoutPlan.statusPlacements);
} else {
statusRenderer.clear(statusHost);
}
if (settings.layoutNotifEnabledUnlocked && layoutPlan.notificationPlacements != null) {
notificationRenderer.renderPlacements(notificationHost, layoutPlan.notificationPlacements);
} else {
notificationRenderer.clear(notificationHost);
}
return new RenderResult(true, stockSourcesChanged, chipSourcesChanged);
}
private void layoutHostToRoot(View root, ViewGroup host) {
if (root == null || host == null || root.getWidth() <= 0 || root.getHeight() <= 0) {
return;
}
if (host.getLeft() == 0
&& host.getTop() == 0
&& host.getRight() == root.getWidth()
&& host.getBottom() == root.getHeight()
&& host.getMeasuredWidth() == root.getWidth()
&& host.getMeasuredHeight() == root.getHeight()) {
return;
}
int widthSpec = View.MeasureSpec.makeMeasureSpec(root.getWidth(), View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(root.getHeight(), View.MeasureSpec.EXACTLY);
host.measure(widthSpec, heightSpec);
host.layout(0, 0, root.getWidth(), root.getHeight());
}
public void clearAll() {
clockRenderer.clearAll();
statusRenderer.clearAll();
statusRenderer.clearHeldSignals();
notificationRenderer.clearAll();
chipRenderer.clearAll();
sourceCollector.clear();
clockBaselineDeltaByRoot.clear();
lastLayoutSignatureByRoot.clear();
lastCollectedIconsByRoot.clear();
lastLayoutPlanByRoot.clear();
lastClockGeometryByRoot.clear();
lastClockSourceGeometryByRoot.clear();
chipPlacementController.clear();
}
public void beginRootTransition() {
statusRenderer.beginTransitionHold();
sourceCollector.clear();
lastLayoutSignatureByRoot.clear();
lastCollectedIconsByRoot.clear();
lastLayoutPlanByRoot.clear();
lastClockGeometryByRoot.clear();
}
private boolean isClockOnlySignatureChange(
LayoutInputSignature previousSignature,
LayoutInputSignature signature
) {
return previousSignature != null
&& signature != null
&& signature.nonClockLayoutSignature() == previousSignature.nonClockLayoutSignature();
}
private ClockLayout buildClockLayout(
View root,
RootAnchors anchors,
SbtSettings settings
) {
return buildClockLayout(
root,
anchors != null ? anchors.clockView : null,
anchors != null ? anchors.clockContainer : null,
settings,
UnlockedLayoutPlanner.cutoutBounds(ViewGeometry.middleCutout(root), settings != null ? settings.clockMiddleCutoutGapPx : 0));
}
private ClockLayout buildClockLayout(
View root,
TextView source,
View clockContainer,
SbtSettings settings,
Bounds cutoutBounds
) {
if (root == null || source == null || settings == null) {
return null;
}
Bounds sourceBounds = ViewGeometry.boundsRelativeTo(root, source);
if (sourceBounds == null || sourceBounds.width <= 0 || sourceBounds.height <= 0) {
return null;
}
ClockLayoutTextFactory.Model model = ClockLayoutTextFactory.build(source, settings);
if (model == null || model.isEmpty()) {
return null;
}
return ClockLayout.measure(
source,
model,
UnlockedLayoutPlanner.positionFor(settings.clockPosition),
stableClockBaselineY(root, source, clockContainer)
- offsetScaler.clock(settings.clockVerticalOffsetPx, source),
root.getWidth(),
cutoutBounds,
settings.clockMiddleAutoNearestSide,
UnlockedLayoutPlanner.sideFor(settings.clockMiddleCutoutSide));
}
private Integer clockGeometrySignature(
View root,
ClockLayout clockLayout,
SbtSettings settings,
Bounds clockCutoutBounds
) {
ClockPlacement placement = clockTextPlacement(root, clockLayout, settings, clockCutoutBounds);
if (placement == null) {
return 0;
}
int result = 17;
result = 31 * result + boundsGeometrySignature(placement.bounds);
result = 31 * result + placement.rows.size();
for (ClockRow row : placement.rows) {
result = 31 * result + row.x;
result = 31 * result + row.y;
result = 31 * result + row.width;
result = 31 * result + row.height;
result = 31 * result + row.clipLeft;
result = 31 * result + row.rowIndex;
result = 31 * result + row.segment.ordinal();
}
return result;
}
private ClockPlacement clockTextPlacement(
View root,
ClockLayout clockLayout,
SbtSettings settings,
Bounds clockCutoutBounds
) {
if (clockLayout == null) {
return null;
}
if (settings != null
&& root != null
&& clockLayout.canSplitAroundCutout(root.getWidth(), clockCutoutBounds)) {
ArrayList<ClockPlacement> placements = new ArrayList<>();
ArrayList<ClockLayout> splitLayouts = clockLayout.splitAroundCutout(
root.getWidth(),
clockCutoutBounds,
settings.clockMiddleAutoNearestSide,
UnlockedLayoutPlanner.sideFor(settings.clockMiddleCutoutSide));
if (splitLayouts.size() < 2) {
return clockLayout.placement();
}
for (ClockLayout split : splitLayouts) {
placements.add(split.placement());
}
return UnlockedLayoutPlanner.mergeClockPlacements(placements);
}
return clockLayout.placement();
}
private ClockPlacement clockTextPlacementForPrevious(
View root,
ClockLayout clockLayout,
SbtSettings settings,
Bounds clockCutoutBounds,
ClockPlacement previousPlacement
) {
if (previousPlacement == null || previousPlacement.rows == null) {
return clockTextPlacement(root, clockLayout, settings, clockCutoutBounds);
}
if (!hasSplitClockRows(previousPlacement)) {
return clockLayout != null ? clockLayout.placement() : null;
}
return clockTextPlacement(root, clockLayout, settings, clockCutoutBounds);
}
private boolean hasSplitClockRows(ClockPlacement placement) {
if (placement == null || placement.rows == null) {
return false;
}
for (ClockRow row : placement.rows) {
if (row.segment != ClockRowSegment.FULL) {
return true;
}
}
return false;
}
private ClockPlacement clockPlacementWithTextFromModel(
ClockPlacement previousPlacement,
TextView source,
SbtSettings settings
) {
if (previousPlacement == null
|| previousPlacement.rows == null
|| source == null
|| settings == null) {
return null;
}
ClockLayoutTextFactory.Model model = ClockLayoutTextFactory.build(source, settings);
if (model == null || model.isEmpty()) {
return null;
}
ArrayList<ClockRow> rows = new ArrayList<>();
for (ClockRow row : previousPlacement.rows) {
if (row.rowIndex < 0 || row.rowIndex >= model.rows.size()) {
return null;
}
ClockLayoutTextFactory.Row modelRow = model.rows.get(row.rowIndex);
CharSequence text = switch (row.segment) {
case LEFT -> modelRow.leftText;
case RIGHT -> modelRow.rightText;
case FULL -> modelRow.text;
};
rows.add(new ClockRow(
text,
row.x,
row.y,
row.width,
row.height,
row.clipLeft,
row.rowIndex,
row.segment));
}
return new ClockPlacement(
source,
model.contentDescription,
rows,
previousPlacement.bounds);
}
private Integer clockSourceGeometrySignature(TextView source) {
if (source == null) {
return 0;
}
int result = 17;
result = 31 * result + System.identityHashCode(source);
result = 31 * result + source.getWidth();
result = 31 * result + source.getHeight();
result = 31 * result + source.getMeasuredWidth();
result = 31 * result + source.getMeasuredHeight();
result = 31 * result + source.getBaseline();
result = 31 * result + source.getLineHeight();
result = 31 * result + Float.floatToIntBits(source.getTextSize());
result = 31 * result + source.getGravity();
result = 31 * result + (source.getIncludeFontPadding() ? 1 : 0);
result = 31 * result + (source.getTypeface() != null
? System.identityHashCode(source.getTypeface())
: 0);
result = 31 * result + source.getPaddingLeft();
result = 31 * result + source.getPaddingTop();
result = 31 * result + source.getPaddingRight();
result = 31 * result + source.getPaddingBottom();
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
result = 31 * result + Float.floatToIntBits(source.getLetterSpacing());
String fontFeatures = source.getFontFeatureSettings();
result = 31 * result + (fontFeatures != null ? fontFeatures.hashCode() : 0);
}
return result;
}
private ClockPlacement clockPlacementWithUpdatedText(
ClockPlacement previous,
ClockPlacement currentText
) {
if (previous == null || currentText == null) {
return null;
}
ArrayList<ClockRow> rows = new ArrayList<>();
for (int i = 0; i < previous.rows.size(); i++) {
ClockRow previousRow = previous.rows.get(i);
ClockRow currentRow = matchingClockRow(previousRow, currentText.rows);
if (currentRow == null) {
return null;
}
rows.add(new ClockRow(
currentRow.text,
previousRow.x,
previousRow.y,
previousRow.width,
previousRow.height,
previousRow.clipLeft,
previousRow.rowIndex,
previousRow.segment));
}
return new ClockPlacement(previous.source, currentText.contentDescription, rows, previous.bounds);
}
private ClockRow matchingClockRow(ClockRow previousRow, ArrayList<ClockRow> currentRows) {
if (previousRow == null || currentRows == null) {
return null;
}
for (ClockRow row : currentRows) {
if (row.rowIndex == previousRow.rowIndex && row.segment == previousRow.segment) {
return row;
}
}
return null;
}
private String clockFastPathMissReason(
LayoutPlan previousPlan,
Integer previousGeometry,
Integer currentGeometry,
ClockPlacement updatedClock
) {
if (previousPlan == null) {
return "noPlan";
}
if (currentGeometry == null) {
return "noGeometry";
}
if (!currentGeometry.equals(previousGeometry)) {
return "geometryChanged";
}
if (previousPlan.clockPlacement != null && updatedClock == null) {
return "rowMismatch";
}
return null;
}
private static int boundsGeometrySignature(Bounds bounds) {
if (bounds == null) {
return 0;
}
int result = 17;
result = 31 * result + bounds.left;
result = 31 * result + bounds.top;
result = 31 * result + bounds.width;
result = 31 * result + bounds.height;
return result;
}
private int stableClockBaselineY(View root, TextView source, View clockContainer) {
Bounds sourceBounds = ViewGeometry.boundsRelativeTo(root, source);
int sourceBaseline = source.getBaseline() > 0
? source.getBaseline()
: (sourceBounds != null ? Math.max(0, (sourceBounds.height * 3) / 4) : 0);
Bounds containerBounds = ViewGeometry.boundsRelativeTo(root, clockContainer);
if (containerBounds != null
&& containerBounds.width > 0
&& containerBounds.height > 0
&& sourceBounds != null
&& sourceBounds.height > 0) {
int delta = sourceBounds.top + sourceBaseline - containerBounds.top;
Integer previousDelta = clockBaselineDeltaByRoot.get(root);
int tolerance = Math.max(4, containerBounds.height / 10);
if (previousDelta != null && Math.abs(delta - previousDelta) > tolerance) {
return containerBounds.top + previousDelta;
}
clockBaselineDeltaByRoot.put(root, delta);
return containerBounds.top + delta;
}
return sourceBounds != null ? sourceBounds.top + sourceBaseline : 0;
}
public record RenderResult(
boolean layoutChanged,
boolean stockSourcesChanged,
boolean chipSourcesChanged
) {
public RenderResult(boolean layoutChanged, boolean stockSourcesChanged) {
this(layoutChanged, stockSourcesChanged, stockSourcesChanged);
}
}
}
@@ -0,0 +1,620 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.view.ViewGroup;
import java.util.ArrayList;
import java.util.Collections;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.AnchorSide;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.LayoutItemKind;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.LayoutPosition;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.SplitRegion;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
final class UnlockedLayoutBand extends StatusBarLayoutSolver.LayoutBand {
final ViewGroup host;
final SnapshotRenderer renderer;
final ArrayList<SnapshotPlacement> rawPlacements;
final boolean showDotIfTruncated;
final Bounds reservedBounds;
final ClockLayout clockLayout;
ArrayList<SnapshotPlacement> placements;
boolean reverseForPlacement;
Bounds placedBounds;
int clipStartPx;
int clipEndPx;
private UnlockedLayoutBand(
LayoutItemKind kind,
ViewGroup host,
SnapshotRenderer renderer,
ArrayList<SnapshotPlacement> rawPlacements,
LayoutPosition position,
boolean middleAutoNearestSide,
AnchorSide middleSide,
boolean showDotIfTruncated,
Bounds reservedBounds,
ClockLayout clockLayout,
SplitRegion splitRegion
) {
super(kind, position, middleAutoNearestSide, middleSide, splitRegion);
this.host = host;
this.renderer = renderer;
this.rawPlacements = rawPlacements;
this.showDotIfTruncated = showDotIfTruncated;
this.reservedBounds = reservedBounds;
this.clockLayout = clockLayout;
}
static UnlockedLayoutBand clock(
ViewGroup host,
ClockLayout clockLayout,
LayoutPosition position,
SbtSettings settings
) {
return new UnlockedLayoutBand(
LayoutItemKind.CLOCK,
host,
null,
null,
position,
settings.clockMiddleAutoNearestSide,
sideFor(settings.clockMiddleCutoutSide),
false,
null,
clockLayout,
SplitRegion.NONE);
}
static UnlockedLayoutBand fixedClock(
ViewGroup host,
ClockLayout clockLayout,
SplitRegion splitRegion,
SbtSettings settings
) {
return new UnlockedLayoutBand(
LayoutItemKind.CLOCK,
host,
null,
null,
LayoutPosition.MIDDLE,
settings.clockMiddleAutoNearestSide,
sideFor(settings.clockMiddleCutoutSide),
false,
null,
clockLayout,
splitRegion);
}
static UnlockedLayoutBand icons(
LayoutItemKind kind,
ViewGroup host,
SnapshotRenderer renderer,
ArrayList<SnapshotPlacement> rawPlacements,
LayoutPosition position,
boolean middleAutoNearestSide,
AnchorSide middleSide,
boolean showDotIfTruncated
) {
return new UnlockedLayoutBand(
kind,
host,
renderer,
rawPlacements,
position,
middleAutoNearestSide,
middleSide,
showDotIfTruncated,
null,
null,
SplitRegion.NONE);
}
@Override
protected void prepareForSide(AnchorSide side, String sortOrder) {
if (kind == LayoutItemKind.CLOCK) {
placements = null;
placedBounds = clockLayout != null ? clockLayout.bounds() : reservedBounds;
return;
}
placements = new ArrayList<>(activePlacements());
reverseForPlacement = "reversed".equals(sortOrder)
|| ("automatic".equals(sortOrder)
&& ((side == AnchorSide.LEFT && kind == LayoutItemKind.NOTIFICATIONS)
|| (side == AnchorSide.RIGHT && kind == LayoutItemKind.STATUS)));
placedBounds = null;
}
@Override
protected int width() {
return Math.max(0, naturalWidth() - clipStartPx - clipEndPx);
}
int naturalWidth() {
if (kind == LayoutItemKind.CLOCK) {
if (clockLayout != null) {
return clockLayout.width();
}
return reservedBounds != null ? reservedBounds.width : 0;
}
return sumWidths(activePlacements());
}
@Override
protected boolean canClipContinuously() {
return (kind == LayoutItemKind.CHIP || kind == LayoutItemKind.CLOCK)
&& naturalWidth() > clipStartPx + clipEndPx;
}
@Override
protected boolean clipContinuous(AnchorSide wallSide, int amountPx) {
int remaining = naturalWidth() - clipStartPx - clipEndPx;
if (amountPx <= 0 || remaining <= 0 || !canClipContinuously()) {
return false;
}
int amount = Math.min(amountPx, remaining);
if (wallSide == AnchorSide.RIGHT) {
clipEndPx += amount;
} else {
clipStartPx += amount;
}
return true;
}
@Override
protected int wallDotSlack(AnchorSide side) {
SnapshotPlacement placement = wallPlacement(side);
if (placement == null || !placement.overflowDot) {
return 0;
}
int collisionWidth = relativeCollisionBounds(placement).width;
int dotWidth = overflowDotVisualWidth(placement);
return Math.max(0, (collisionWidth - dotWidth) / 2);
}
int height() {
if (kind == LayoutItemKind.CLOCK) {
if (clockLayout != null) {
return clockLayout.height();
}
return reservedBounds != null ? reservedBounds.height : 0;
}
int height = 0;
for (SnapshotPlacement placement : activePlacements()) {
height = Math.max(height, collisionBounds(placement).height);
}
return height;
}
int top() {
if (kind == LayoutItemKind.CLOCK) {
if (clockLayout != null) {
return clockLayout.top();
}
return reservedBounds != null ? reservedBounds.top : 0;
}
int top = Integer.MAX_VALUE;
for (SnapshotPlacement placement : activePlacements()) {
top = Math.min(top, collisionBounds(placement).top);
}
return top == Integer.MAX_VALUE ? 0 : top;
}
@Override
protected boolean shrinkOneIcon() {
if (kind == LayoutItemKind.NOTIFICATIONS) {
return shrinkIcon(false);
}
if (kind == LayoutItemKind.STATUS) {
return shrinkIcon(true);
}
return false;
}
@Override
protected boolean removeOverflowDot() {
if (placements == null || placements.isEmpty()) {
return false;
}
ArrayList<SnapshotPlacement> withoutDot = new ArrayList<>();
boolean removed = false;
for (SnapshotPlacement placement : placements) {
if (placement.overflowDot) {
removed = true;
} else {
withoutDot.add(placement);
}
}
if (!removed) {
return false;
}
if (withoutDot.isEmpty()) {
withoutDot.add(emptyIconContainerPlacement());
}
placements = withoutDot;
return true;
}
@Override
protected Object captureState() {
return new LayoutBandState(placements == null, new ArrayList<>(activePlacements()));
}
@Override
protected void restoreState(Object state) {
if (!(state instanceof LayoutBandState bandState)) {
return;
}
placements = bandState.placementsWereNull ? null : new ArrayList<>(bandState.placements);
}
@Override
protected boolean createdOverflowDotSince(Object state) {
if (!(state instanceof LayoutBandState bandState)) {
return hasOverflowDot(activePlacements());
}
return !hasOverflowDot(bandState.placements) && hasOverflowDot(activePlacements());
}
@Override
protected void place(int left) {
if (kind == LayoutItemKind.CLOCK) {
placedBounds = clockLayout != null
? clockLayout.place(left)
: new Bounds(left, top(), width(), height());
if (placedBounds != null && width() != naturalWidth()) {
placedBounds = new Bounds(
placedBounds.left + clipStartPx,
placedBounds.top,
width(),
placedBounds.height);
}
return;
}
if (reverseForPlacement) {
Collections.reverse(placements);
}
int cursor = left;
int rowTop = top();
int clipLeft = left + clipStartPx;
int clipRight = left + naturalWidth() - clipEndPx;
ArrayList<SnapshotPlacement> positioned = new ArrayList<>();
for (SnapshotPlacement placement : placements) {
Bounds original = placement.bounds;
Bounds collision = relativeCollisionBounds(placement);
int collisionLeft = cursor;
int collisionRight = cursor + collision.width;
Bounds bounds = new Bounds(
cursor - collision.left,
rowTop - collision.top,
original.width,
original.height);
SnapshotPlacement clipped = clipPlacement(
placement,
bounds,
collision,
Math.max(collisionLeft, clipLeft),
Math.min(collisionRight, clipRight));
if (clipped != null) {
positioned.add(clipped);
}
cursor += collision.width;
}
if (positioned.isEmpty() && kind == LayoutItemKind.CHIP && height() > 0) {
positioned.add(emptyClippedPlacement(left + clipStartPx, rowTop, height()));
}
placements = positioned;
placedBounds = bounds();
}
Bounds bounds() {
if (kind == LayoutItemKind.CLOCK) {
return placedBounds;
}
Bounds union = null;
if (placements == null) {
return null;
}
for (SnapshotPlacement placement : placements) {
Bounds bounds = collisionBounds(placement);
union = union == null ? bounds : union.union(bounds);
}
return union;
}
@Override
protected ArrayList<Bounds> collisionBoxes() {
ArrayList<Bounds> boxes = new ArrayList<>();
if (kind == LayoutItemKind.CLOCK) {
if (clockLayout != null) {
int clipLeft = clipStartPx;
int clipRight = naturalWidth() - clipEndPx;
for (Bounds box : clockLayout.collisionBoxes()) {
int left = Math.max(box.left, clipLeft);
int right = Math.min(box.left + box.width, clipRight);
if (right >= left) {
boxes.add(new Bounds(left - clipStartPx, box.top, right - left, box.height));
}
}
} else if (reservedBounds != null) {
boxes.add(new Bounds(0, reservedBounds.top, width(), reservedBounds.height));
}
return boxes;
}
Bounds bounds = new Bounds(0, top(), width(), height());
if (bounds.height > 0) {
boxes.add(bounds);
}
return boxes;
}
@Override
protected ArrayList<Bounds> placedCollisionBounds() {
ArrayList<Bounds> boxes = new ArrayList<>();
int left = kind == LayoutItemKind.CLOCK && placedBounds != null
? placedBounds.left
: 0;
for (Bounds box : collisionBoxes()) {
boxes.add(new Bounds(left + box.left, box.top, box.width, box.height));
}
if (kind != LayoutItemKind.CLOCK && placedBounds != null) {
boxes.clear();
boxes.add(placedBounds);
}
return boxes;
}
private SnapshotPlacement wallPlacement(AnchorSide side) {
if (kind == LayoutItemKind.CLOCK) {
return null;
}
ArrayList<SnapshotPlacement> icons = activePlacements();
if (icons.isEmpty()) {
return null;
}
boolean wallAtStart = side == AnchorSide.RIGHT;
int index;
if (wallAtStart) {
index = reverseForPlacement ? icons.size() - 1 : 0;
} else {
index = reverseForPlacement ? 0 : icons.size() - 1;
}
return icons.get(index);
}
private int overflowDotVisualWidth(SnapshotPlacement placement) {
Bounds bounds = placement.bounds;
int base = Math.max(1, Math.min(bounds.width, bounds.height));
int radius = Math.max(1, Math.round(base * 0.16f));
return Math.min(bounds.width, radius * 2 + 2);
}
private Bounds collisionBounds(SnapshotPlacement placement) {
Bounds relative = relativeCollisionBounds(placement);
Bounds bounds = placement.bounds;
return new Bounds(
bounds.left + relative.left,
bounds.top + relative.top,
relative.width,
relative.height);
}
private Bounds relativeCollisionBounds(SnapshotPlacement placement) {
if (placement.contentBounds != null
&& placement.contentBounds.width >= 0
&& placement.contentBounds.height > 0) {
return placement.contentBounds;
}
Bounds bounds = placement.bounds;
return new Bounds(0, 0, bounds.width, bounds.height);
}
private boolean shrinkIcon(boolean fromStart) {
ArrayList<SnapshotPlacement> icons = new ArrayList<>(activePlacements());
int removeIndex = nonDotIndex(icons, fromStart);
if (removeIndex < 0) {
return false;
}
boolean hadDot = hasOverflowDot(icons);
icons.remove(removeIndex);
if (showDotIfTruncated && !hadDot) {
SnapshotPlacement dot = dotPlacement(dotWidth(icons), dotHeight(icons));
if (fromStart) {
icons.add(0, dot);
} else {
icons.add(dot);
}
}
placements = icons;
return true;
}
private int nonDotIndex(ArrayList<SnapshotPlacement> icons, boolean fromStart) {
int start = fromStart ? 0 : icons.size() - 1;
int end = fromStart ? icons.size() : -1;
int step = fromStart ? 1 : -1;
for (int i = start; i != end; i += step) {
if (isShrinkableIcon(icons.get(i))) {
return i;
}
}
return -1;
}
private boolean isShrinkableIcon(SnapshotPlacement placement) {
return placement != null
&& !placement.overflowDot
&& (placement.key == null || !placement.key.startsWith(SnapshotKeys.EMPTY_ICON_CONTAINER));
}
private boolean hasOverflowDot(ArrayList<SnapshotPlacement> icons) {
for (SnapshotPlacement icon : icons) {
if (icon.overflowDot) {
return true;
}
}
return false;
}
private ArrayList<SnapshotPlacement> activePlacements() {
if (placements != null) {
return placements;
}
return rawPlacements != null ? rawPlacements : new ArrayList<>();
}
private int dotWidth(ArrayList<SnapshotPlacement> icons) {
if (!icons.isEmpty()) {
return icons.get(0).bounds.width;
}
if (rawPlacements != null && !rawPlacements.isEmpty()) {
return rawPlacements.get(0).bounds.width;
}
return 0;
}
private int dotHeight(ArrayList<SnapshotPlacement> icons) {
if (!icons.isEmpty()) {
return icons.get(0).bounds.height;
}
if (rawPlacements != null && !rawPlacements.isEmpty()) {
return rawPlacements.get(0).bounds.height;
}
return 0;
}
private SnapshotPlacement dotPlacement(int width, int height) {
return new SnapshotPlacement(
null,
new Bounds(0, top(), Math.max(1, width), Math.max(1, height)),
SnapshotKeys.OVERFLOW_DOT + ":" + kind.name().toLowerCase(java.util.Locale.ROOT),
false,
true,
null,
new Bounds(0, 0, Math.max(1, width), Math.max(1, height)),
0);
}
private SnapshotPlacement emptyIconContainerPlacement() {
int height = Math.max(1, dotHeight(activePlacements()));
return new SnapshotPlacement(
null,
new Bounds(0, top(), 1, height),
SnapshotKeys.EMPTY_ICON_CONTAINER + ":" + kind.name().toLowerCase(java.util.Locale.ROOT),
false,
false,
null,
new Bounds(0, 0, 0, height),
0);
}
private SnapshotPlacement clipPlacement(
SnapshotPlacement placement,
Bounds bounds,
Bounds collision,
int clippedCollisionLeft,
int clippedCollisionRight
) {
int collisionWidth = Math.max(0, clippedCollisionRight - clippedCollisionLeft);
int removeLeft = Math.max(0, clippedCollisionLeft - (bounds.left + collision.left));
int removeRight = Math.max(0, bounds.left + collision.left + collision.width - clippedCollisionRight);
if (collisionWidth <= 0) {
if (kind == LayoutItemKind.CHIP) {
return emptyClippedPlacement(
bounds.left + removeLeft,
bounds.top + collision.top,
collision.height);
}
return null;
}
Bounds clippedBounds = new Bounds(
bounds.left + removeLeft,
bounds.top,
Math.max(1, bounds.width - removeLeft - removeRight),
bounds.height);
Bounds clippedContent = placement.contentBounds != null
? new Bounds(
placement.contentBounds.left,
placement.contentBounds.top,
collisionWidth,
placement.contentBounds.height)
: null;
return new SnapshotPlacement(
placement.source,
clippedBounds,
placement.key,
placement.signal,
placement.overflowDot,
placement.heldSignal,
clippedContent,
placement.sourceOffsetX + removeLeft);
}
private int sumWidths(ArrayList<SnapshotPlacement> snapshotPlacements) {
int width = 0;
if (snapshotPlacements != null) {
for (SnapshotPlacement placement : snapshotPlacements) {
width += collisionBounds(placement).width;
}
}
return width;
}
private SnapshotPlacement emptyClippedPlacement(int left, int top, int height) {
return new SnapshotPlacement(
null,
new Bounds(left, top, 1, Math.max(1, height)),
SnapshotKeys.EMPTY_CHIP,
false,
false,
null,
new Bounds(0, 0, 0, Math.max(1, height)),
0);
}
ClockPlacement clockPlacement() {
if (clockLayout == null) {
return null;
}
ClockPlacement placement = clockLayout.placement();
if (placement == null || placement.bounds == null || clipStartPx + clipEndPx <= 0) {
return placement;
}
int clipLeft = placement.bounds.left + clipStartPx;
int clipRight = placement.bounds.left + naturalWidth() - clipEndPx;
ArrayList<ClockRow> rows = new ArrayList<>();
for (ClockRow row : placement.rows) {
int rowLeft = placement.bounds.left + row.x;
int rowRight = rowLeft + row.width;
int left = Math.max(rowLeft, clipLeft);
int right = Math.min(rowRight, clipRight);
if (right <= left) {
continue;
}
rows.add(new ClockRow(
row.text,
left - clipLeft,
row.y,
right - left,
row.height,
row.clipLeft + left - rowLeft,
row.rowIndex,
row.segment));
}
return new ClockPlacement(
placement.source,
placement.contentDescription,
rows,
new Bounds(clipLeft, placement.bounds.top, Math.max(0, clipRight - clipLeft), placement.bounds.height));
}
private static AnchorSide sideFor(String value) {
return "right".equals(value) ? AnchorSide.RIGHT : AnchorSide.LEFT;
}
private record LayoutBandState(boolean placementsWereNull, ArrayList<SnapshotPlacement> placements) {
}
}
@@ -0,0 +1,459 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.graphics.Rect;
import android.view.View;
import android.view.ViewGroup;
import java.util.ArrayList;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Anchor;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.AnchorKind;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.AnchorSide;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.LayoutEdges;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.LayoutItemKind;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.LayoutPosition;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.PlacedBand;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Region;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.RegionPlan;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.SplitRegion;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
final class UnlockedLayoutPlanner {
private final SnapshotRenderer statusRenderer;
private final SnapshotRenderer notificationRenderer;
private final SnapshotRenderer chipRenderer;
private final UnlockedChipPlacementController chipPlacementController;
private final UnlockedVerticalOffsetScaler offsetScaler;
UnlockedLayoutPlanner(
SnapshotRenderer statusRenderer,
SnapshotRenderer notificationRenderer,
SnapshotRenderer chipRenderer,
UnlockedChipPlacementController chipPlacementController,
UnlockedVerticalOffsetScaler offsetScaler
) {
this.statusRenderer = statusRenderer;
this.notificationRenderer = notificationRenderer;
this.chipRenderer = chipRenderer;
this.chipPlacementController = chipPlacementController;
this.offsetScaler = offsetScaler;
}
LayoutPlan solve(
View root,
ViewGroup clockHost,
ViewGroup chipHost,
ViewGroup statusHost,
ViewGroup notificationHost,
RootAnchors anchors,
CollectedIcons collectedIcons,
SbtSettings settings,
ClockLayout clockLayout
) {
ArrayList<UnlockedLayoutBand> bands = new ArrayList<>();
Rect cutout = ViewGeometry.middleCutout(root);
Bounds layoutCutoutBounds = cutoutBounds(cutout, settings.layoutPaddingCutoutPx);
Bounds clockCutoutBounds = cutoutBounds(cutout, settings.clockMiddleCutoutGapPx);
if (settings.clockEnabledUnlocked && clockLayout != null && clockLayout.width() > 0) {
bands.add(UnlockedLayoutBand.clock(
clockHost,
clockLayout,
positionFor(settings.clockPosition),
settings));
}
if (settings.layoutChipEnabledUnlocked) {
ArrayList<SnapshotPlacement> placements = chipPlacementController.placements(
root,
collectedIcons.statusChips,
collectedIcons.statusChipMetrics,
settings);
if (!placements.isEmpty()) {
bands.add(UnlockedLayoutBand.icons(
LayoutItemKind.CHIP,
chipHost,
chipRenderer,
placements,
positionFor(settings.layoutChipPosition),
settings.layoutChipMiddleAutoNearestSide,
sideFor(settings.layoutChipMiddleSide),
false));
}
}
if (settings.layoutStatusEnabledUnlocked) {
ArrayList<SnapshotPlacement> rawPlacements =
statusRenderer.rawPlacements(root, collectedIcons.statusIcons);
ArrayList<SnapshotPlacement> placements =
offsetPlacements(
rawPlacements,
offsetScaler.status(
settings.layoutStatusVerticalOffsetPx,
representativeHeight(rawPlacements)));
if (!placements.isEmpty()) {
bands.add(UnlockedLayoutBand.icons(
LayoutItemKind.STATUS,
statusHost,
statusRenderer,
placements,
positionFor(settings.layoutStatusPosition),
settings.layoutStatusMiddleAutoNearestSide,
sideFor(settings.layoutStatusMiddleSide),
settings.layoutStatusShowDotIfTruncated));
}
}
if (settings.layoutNotifEnabledUnlocked) {
ArrayList<SnapshotPlacement> rawPlacements =
notificationRenderer.rawPlacements(root, collectedIcons.notificationIcons);
ArrayList<SnapshotPlacement> placements =
offsetPlacements(
rawPlacements,
offsetScaler.notification(
settings.layoutNotifVerticalOffsetPx,
representativeHeight(rawPlacements)));
if (!placements.isEmpty()) {
bands.add(UnlockedLayoutBand.icons(
LayoutItemKind.NOTIFICATIONS,
notificationHost,
notificationRenderer,
placements,
positionFor(settings.layoutNotifPosition),
settings.layoutNotifMiddleAutoNearestSide,
sideFor(settings.layoutNotifMiddleSide),
settings.layoutNotifShowDotIfTruncated));
}
}
if (bands.isEmpty()) {
return new LayoutPlan(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
}
ArrayList<PlacedBand> placedBands = new ArrayList<>();
ArrayList<LayoutItemKind> itemOrder = orderedKinds(settings.layoutItemOrder);
splitClockIfNeeded(root, bands, itemOrder, clockCutoutBounds, settings);
LayoutEdges edges = layoutEdges(root, settings, anchors);
int itemPadding = Math.max(0, settings.layoutItemPaddingPx);
RegionPlan<UnlockedLayoutBand> regionPlan = StatusBarLayoutSolver.planRegions(
root.getWidth(),
edges,
bands,
itemOrder,
layoutCutoutBounds,
itemPadding);
for (Region<UnlockedLayoutBand> region : regionPlan.regions()) {
StatusBarLayoutSolver.truncateToFit(
region.bands,
region.width(),
regionPlan.middleSide(),
settings.layoutNotifSortOrder,
itemPadding);
}
StatusBarLayoutSolver.placeStackedBands(
StatusBarLayoutSolver.bandsForPosition(bands, LayoutPosition.LEFT, itemOrder),
new Anchor(edges.left(), AnchorSide.RIGHT, AnchorKind.WALL),
placedBands,
settings.layoutNotifSortOrder,
false,
itemPadding);
StatusBarLayoutSolver.placeStackedBands(
StatusBarLayoutSolver.bandsForPosition(bands, LayoutPosition.RIGHT, itemOrder),
new Anchor(edges.right(), AnchorSide.LEFT, AnchorKind.WALL),
placedBands,
settings.layoutNotifSortOrder,
false,
itemPadding);
ArrayList<UnlockedLayoutBand> middleBands = StatusBarLayoutSolver.middleBandsForPlacement(
bands,
itemOrder,
regionPlan.middleSide());
if (!middleBands.isEmpty()) {
Anchor anchor = StatusBarLayoutSolver.anchorForMiddle(
root.getWidth(),
middleBands,
placedBands,
layoutCutoutBounds,
regionPlan.middleSide(),
itemPadding);
StatusBarLayoutSolver.placeStackedBands(
middleBands,
anchor,
placedBands,
settings.layoutNotifSortOrder,
true,
itemPadding);
}
ArrayList<SnapshotPlacement> statusPlacements = new ArrayList<>();
ArrayList<SnapshotPlacement> notificationPlacements = new ArrayList<>();
ArrayList<SnapshotPlacement> chipPlacements = new ArrayList<>();
ArrayList<ClockPlacement> clockPlacements = new ArrayList<>();
for (UnlockedLayoutBand band : bands) {
if (band.kind == LayoutItemKind.CLOCK && band.clockLayout != null) {
clockPlacements.add(band.clockPlacement());
} else if (band.kind == LayoutItemKind.CHIP && band.placements != null) {
chipPlacements.addAll(band.placements);
} else if (band.kind == LayoutItemKind.STATUS && band.placements != null) {
statusPlacements.addAll(band.placements);
} else if (band.kind == LayoutItemKind.NOTIFICATIONS && band.placements != null) {
notificationPlacements.addAll(band.placements);
}
}
return new LayoutPlan(
mergeClockPlacements(clockPlacements),
chipPlacements,
statusPlacements,
notificationPlacements);
}
private ArrayList<SnapshotPlacement> offsetPlacements(ArrayList<SnapshotPlacement> placements, int verticalOffsetPx) {
if (placements == null || placements.isEmpty() || verticalOffsetPx == 0) {
return placements;
}
ArrayList<SnapshotPlacement> result = new ArrayList<>();
int dy = -verticalOffsetPx;
for (SnapshotPlacement placement : placements) {
Bounds bounds = placement.bounds;
result.add(new SnapshotPlacement(
placement.source,
new Bounds(bounds.left, bounds.top + dy, bounds.width, bounds.height),
placement.key,
placement.signal,
placement.overflowDot,
placement.heldSignal,
placement.contentBounds,
placement.sourceOffsetX));
}
return result;
}
private int representativeHeight(ArrayList<SnapshotPlacement> placements) {
if (placements == null || placements.isEmpty()) {
return 0;
}
return Math.max(0, placements.get(0).bounds.height);
}
private void splitClockIfNeeded(
View root,
ArrayList<UnlockedLayoutBand> bands,
ArrayList<LayoutItemKind> itemOrder,
Bounds cutoutBounds,
SbtSettings settings
) {
if (root == null || cutoutBounds == null || bands == null || settings == null) {
return;
}
ArrayList<UnlockedLayoutBand> middleBands = StatusBarLayoutSolver.bandsForPosition(
bands,
LayoutPosition.MIDDLE,
itemOrder);
if (middleBands.isEmpty()) {
return;
}
UnlockedLayoutBand clockBand = middleBands.get(0);
if (clockBand.kind != LayoutItemKind.CLOCK
|| clockBand.clockLayout == null
|| !clockBand.clockLayout.canSplitAroundCutout(root.getWidth(), cutoutBounds)) {
return;
}
ArrayList<ClockLayout> splitLayouts = clockBand.clockLayout.splitAroundCutout(
root.getWidth(),
cutoutBounds,
settings.clockMiddleAutoNearestSide,
sideFor(settings.clockMiddleCutoutSide));
if (splitLayouts.size() < 2) {
return;
}
int index = bands.indexOf(clockBand);
if (index < 0) {
return;
}
bands.remove(index);
bands.add(index, UnlockedLayoutBand.fixedClock(clockBand.host, splitLayouts.get(1), SplitRegion.RIGHT, settings));
bands.add(index, UnlockedLayoutBand.fixedClock(clockBand.host, splitLayouts.get(0), SplitRegion.LEFT, settings));
}
private LayoutEdges layoutEdges(
View root,
SbtSettings settings,
RootAnchors anchors
) {
int rootWidth = root != null ? root.getWidth() : 0;
StockInsets stockInsets = stockInsets(root, rootWidth, anchors);
int left = settings.layoutPaddingLeftPx;
int right = rootWidth - settings.layoutPaddingRightPx;
if (settings.layoutPaddingLeftAddStock) {
left += stockInsets.left;
}
if (settings.layoutPaddingRightAddStock) {
right -= stockInsets.right;
}
return new LayoutEdges(left, right);
}
private StockInsets stockInsets(
View root,
int rootWidth,
RootAnchors anchors
) {
int left = firstUsableInset(
rootWidth,
leftInset(root, anchors != null ? anchors.clockContainer : null),
leftInset(root, anchors != null ? anchors.notificationRoot : null),
root != null ? root.getPaddingLeft() : 0);
int right = firstUsableInset(
rootWidth,
rightInset(rootWidth, statusUnion(root, anchors)),
rightPadding(anchors != null ? anchors.statusEndContent : null),
rightPadding(anchors != null ? anchors.statusRoot : null),
root != null ? root.getPaddingRight() : 0);
return new StockInsets(left, right);
}
private int firstUsableInset(int rootWidth, Integer... values) {
int maxPlausible = Math.max(0, rootWidth / 4);
if (values == null) {
return 0;
}
for (Integer value : values) {
if (value != null && value > 0 && value <= maxPlausible) {
return value;
}
}
return 0;
}
private Integer leftInset(View root, View view) {
Bounds bounds = ViewGeometry.boundsRelativeTo(root, view);
if (bounds == null || bounds.width <= 0) {
return null;
}
return bounds.left;
}
private Integer rightInset(int rootWidth, Bounds bounds) {
if (bounds == null || bounds.width <= 0) {
return null;
}
return rootWidth - (bounds.left + bounds.width);
}
private Integer rightPadding(View view) {
return view != null ? view.getPaddingRight() : null;
}
private Bounds statusUnion(View root, RootAnchors anchors) {
if (root == null || anchors == null) {
return null;
}
Bounds statusBounds = ViewGeometry.boundsRelativeTo(root, anchors.statusIcons);
Bounds batteryBounds = ViewGeometry.boundsRelativeTo(root, anchors.battery);
if (statusBounds == null) {
return batteryBounds;
}
if (batteryBounds == null) {
return statusBounds;
}
return statusBounds.union(batteryBounds);
}
static ClockPlacement mergeClockPlacements(ArrayList<ClockPlacement> placements) {
if (placements == null || placements.isEmpty()) {
return null;
}
ClockPlacement first = placements.get(0);
Bounds union = null;
for (ClockPlacement placement : placements) {
if (placement != null && placement.bounds != null) {
union = union == null ? placement.bounds : union.union(placement.bounds);
}
}
if (union == null) {
return first;
}
ArrayList<ClockRow> rows = new ArrayList<>();
for (ClockPlacement placement : placements) {
if (placement == null || placement.rows == null || placement.bounds == null) {
continue;
}
for (ClockRow row : placement.rows) {
rows.add(new ClockRow(
row.text,
placement.bounds.left + row.x - union.left,
placement.bounds.top + row.y - union.top,
row.width,
row.height,
row.clipLeft,
row.rowIndex,
row.segment));
}
}
return new ClockPlacement(first.source, first.contentDescription, rows, union);
}
static Bounds cutoutBounds(Rect cutout, int cutoutGap) {
if (cutout == null) {
return null;
}
int left = cutout.left - cutoutGap;
int right = cutout.right + cutoutGap;
return new Bounds(left, cutout.top, Math.max(0, right - left), cutout.height());
}
static LayoutPosition positionFor(String value) {
if ("right".equals(value)) {
return LayoutPosition.RIGHT;
}
if ("middle".equals(value)) {
return LayoutPosition.MIDDLE;
}
return LayoutPosition.LEFT;
}
static AnchorSide sideFor(String value) {
return "right".equals(value) ? AnchorSide.RIGHT : AnchorSide.LEFT;
}
private ArrayList<LayoutItemKind> orderedKinds(String encodedOrder) {
ArrayList<LayoutItemKind> result = new ArrayList<>();
if (encodedOrder != null) {
for (String token : encodedOrder.split(",")) {
LayoutItemKind kind = kindFor(token != null ? token.trim() : "");
if (kind != null && !result.contains(kind)) {
result.add(kind);
}
}
}
addKindIfMissing(result, LayoutItemKind.CLOCK);
addKindIfMissing(result, LayoutItemKind.CHIP);
addKindIfMissing(result, LayoutItemKind.STATUS);
addKindIfMissing(result, LayoutItemKind.NOTIFICATIONS);
return result;
}
private void addKindIfMissing(ArrayList<LayoutItemKind> kinds, LayoutItemKind kind) {
if (!kinds.contains(kind)) {
kinds.add(kind);
}
}
private LayoutItemKind kindFor(String token) {
if ("clock".equals(token)) {
return LayoutItemKind.CLOCK;
}
if ("chip".equals(token)) {
return LayoutItemKind.CHIP;
}
if ("notifications".equals(token)) {
return LayoutItemKind.NOTIFICATIONS;
}
if ("status".equals(token)) {
return LayoutItemKind.STATUS;
}
return null;
}
private record StockInsets(int left, int right) {
}
}
@@ -0,0 +1,739 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.content.res.Resources;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.view.View;
import android.view.ViewGroup;
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.hooks.ReflectionSupport;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds;
import se.ajpanton.statusbartweak.runtime.mode.SceneKey;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
final class UnlockedStockSourceCollector {
private final WeakHashMap<View, RootAnchors> anchorsByRoot = new WeakHashMap<>();
void clear() {
anchorsByRoot.clear();
}
RootAnchors anchorsFor(View root) {
RootAnchors cached = anchorsByRoot.get(root);
if (cached != null && cached.isUsable()) {
return cached;
}
RootAnchors anchors = discoverAnchors(root);
anchorsByRoot.put(root, anchors);
return anchors;
}
CollectedIcons collect(View root, RootAnchors anchors) {
ArrayList<SnapshotSource> statusIcons = new ArrayList<>();
ArrayList<SnapshotSource> notificationIcons = new ArrayList<>();
ArrayList<SnapshotSource> statusChips = new ArrayList<>();
ArrayList<SnapshotSource> statusChipMetrics = new ArrayList<>();
if (anchors.statusRoot != null) {
collectStatusSnapshotViews(anchors.statusRoot, statusIcons);
}
if (anchors.battery != null && !isDescendantOf(anchors.battery, anchors.statusRoot)) {
collectStatusSnapshotViews(anchors.battery, statusIcons);
}
if (anchors.notificationRoot != null) {
collectNotificationSnapshotViews(anchors.notificationRoot, notificationIcons);
}
collectStatusChipSnapshotViews(root, statusChips, statusChipMetrics);
return new CollectedIcons(
anchors.clockView,
statusIcons,
notificationIcons,
statusChips,
statusChipMetrics);
}
CollectedIcons collectChips(
View root,
RootAnchors anchors,
CollectedIcons previousIcons
) {
ArrayList<SnapshotSource> statusChips = new ArrayList<>();
ArrayList<SnapshotSource> statusChipMetrics = new ArrayList<>();
collectKnownStatusChipSnapshotViews(root, previousIcons, statusChips, statusChipMetrics);
if (statusChips.isEmpty() && statusChipMetrics.isEmpty()) {
collectStatusChipSnapshotViews(root, statusChips, statusChipMetrics);
}
return new CollectedIcons(
anchors != null ? anchors.clockView : null,
previousIcons != null ? previousIcons.statusIcons : new ArrayList<>(),
previousIcons != null ? previousIcons.notificationIcons : new ArrayList<>(),
statusChips,
statusChipMetrics);
}
LayoutInputSignature layoutInputSignature(
View root,
SbtSettings settings,
SceneKey scene,
Rect cutout,
RootAnchors anchors,
CollectedIcons previousIcons
) {
int chipSourceSignature = settings.layoutChipEnabledUnlocked
? chipSourceSignature(root, previousIcons)
: 0;
return new LayoutInputSignature(
scene != null ? scene.toString() : "",
root.getWidth(),
root.getHeight(),
root.getPaddingLeft(),
root.getPaddingRight(),
cutout != null ? cutout.left : Integer.MIN_VALUE,
cutout != null ? cutout.top : Integer.MIN_VALUE,
cutout != null ? cutout.right : Integer.MIN_VALUE,
cutout != null ? cutout.bottom : Integer.MIN_VALUE,
layoutSettingsSignature(settings),
clockTimeSignature(settings),
viewSignatureOrZero(anchors != null ? anchors.clockView : null),
shallowTreeSignature(anchors != null ? anchors.clockContainer : null),
shallowTreeSignatureExcluding(
anchors != null ? anchors.statusRoot : null,
anchors != null ? anchors.clockContainer : null),
shallowTreeSignatureExcluding(
anchors != null ? anchors.statusEndContent : null,
anchors != null ? anchors.clockContainer : null),
shallowTreeSignatureExcluding(
anchors != null ? anchors.statusIcons : null,
anchors != null ? anchors.clockContainer : null),
shallowTreeSignatureExcluding(
anchors != null ? anchors.battery : null,
anchors != null ? anchors.clockContainer : null),
notificationRootSignature(anchors != null ? anchors.notificationRoot : null),
sourceListSignature(previousIcons != null ? previousIcons.statusIcons : null),
sourceListSignature(previousIcons != null ? previousIcons.notificationIcons : null),
chipSourceSignature);
}
private void collectKnownStatusChipSnapshotViews(
View root,
CollectedIcons previousIcons,
ArrayList<SnapshotSource> visibleOut,
ArrayList<SnapshotSource> metricOut
) {
if (root == null || previousIcons == null || visibleOut == null || metricOut == null) {
return;
}
if (previousIcons.statusChips != null) {
for (SnapshotSource source : previousIcons.statusChips) {
View view = source.view();
if (isStatusChipSnapshotCandidate(root, view)) {
visibleOut.add(source);
metricOut.add(source);
return;
}
}
}
if (previousIcons.statusChipMetrics != null) {
for (SnapshotSource source : previousIcons.statusChipMetrics) {
if (isStatusChipMetricCandidate(root, source.view())) {
metricOut.add(source);
return;
}
}
}
}
private RootAnchors discoverAnchors(View root) {
View clockContainer = null;
TextView clockView = null;
View statusRoot = null;
int statusRootPriority = Integer.MAX_VALUE;
View statusEndContent = null;
View battery = null;
View statusIcons = null;
View notificationRoot = null;
ArrayDeque<View> queue = new ArrayDeque<>();
queue.add(root);
while (!queue.isEmpty()) {
View view = queue.removeFirst();
String idName = resolveIdName(view);
if (clockContainer == null && idName.equals("left_clock_container")) {
clockContainer = view;
}
if (clockView == null
&& view instanceof TextView textView
&& view.getClass().getName().contains("QSClockIndicatorView")) {
clockView = textView;
}
if (statusEndContent == null && idName.equals("status_bar_end_side_content")) {
statusEndContent = view;
}
int priority = statusRootPriority(idName);
if (priority < statusRootPriority) {
statusRoot = view;
statusRootPriority = priority;
}
if (statusIcons == null && idName.equals("statusIcons")) {
statusIcons = view;
}
if (battery == null && idName.equals("battery")) {
battery = view;
}
if (notificationRoot == null && idName.equals("notificationIcons")) {
notificationRoot = view;
}
if (clockContainer != null
&& clockView != null
&& statusRootPriority == 0
&& statusEndContent != null
&& statusIcons != null
&& battery != null
&& notificationRoot != null) {
break;
}
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 new RootAnchors(
clockContainer,
clockView,
statusRoot,
statusEndContent,
statusIcons,
battery,
notificationRoot);
}
private int statusRootPriority(String idName) {
if (idName.equals("system_icon_area")) {
return 0;
}
if (idName.equals("status_bar_end_side_content")) {
return 1;
}
if (idName.equals("statusIcons")) {
return 2;
}
return Integer.MAX_VALUE;
}
private void collectStatusSnapshotViews(View source, ArrayList<SnapshotSource> out) {
boolean candidate = isStatusSnapshotCandidate(source);
if (isModernSignalContainer(source)) {
if (candidate) {
out.add(new SnapshotSource(source, SnapshotMode.VIEW));
}
return;
}
if (candidate) {
out.add(new SnapshotSource(source, SnapshotMode.VIEW));
if (isAtomicStatusSource(source)) {
return;
}
}
if (source instanceof ViewGroup group) {
for (int i = 0; i < group.getChildCount(); i++) {
collectStatusSnapshotViews(group.getChildAt(i), out);
}
}
}
private boolean isModernSignalContainer(View view) {
String className = view.getClass().getName();
return className.contains("ModernStatusBarWifiView")
|| className.contains("ModernStatusBarMobileView");
}
private void collectNotificationSnapshotViews(View source, ArrayList<SnapshotSource> out) {
if (isNotificationSnapshotCandidate(source)) {
out.add(new SnapshotSource(source, SnapshotMode.NOTIFICATION_DRAWABLE));
return;
}
if (source instanceof ViewGroup group) {
for (int i = 0; i < group.getChildCount(); i++) {
collectNotificationSnapshotViews(group.getChildAt(i), out);
}
}
}
private void collectStatusChipSnapshotViews(
View root,
ArrayList<SnapshotSource> visibleOut,
ArrayList<SnapshotSource> metricOut
) {
if (root == null || visibleOut == null || metricOut == null) {
return;
}
ArrayDeque<View> queue = new ArrayDeque<>();
queue.add(root);
while (!queue.isEmpty()) {
View view = queue.removeFirst();
if (isStatusChipSnapshotCandidate(root, view)) {
visibleOut.add(new SnapshotSource(view, SnapshotMode.VIEW));
metricOut.add(new SnapshotSource(view, SnapshotMode.VIEW));
return;
}
if (metricOut.isEmpty() && isStatusChipMetricCandidate(root, view)) {
metricOut.add(new SnapshotSource(view, SnapshotMode.VIEW));
}
if (view instanceof ViewGroup group) {
for (int i = 0; i < group.getChildCount(); i++) {
queue.addLast(group.getChildAt(i));
}
}
}
}
private boolean isStatusChipSnapshotCandidate(View root, View view) {
if (view == null || view == root || view.getVisibility() != View.VISIBLE) {
return false;
}
if (ReflectionSupport.getBooleanField(view, "sbtStatusChipForcedHidden", false)) {
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 || root == null || width >= root.getWidth() / 2) {
return false;
}
Bounds bounds = ViewGeometry.boundsRelativeTo(root, view);
if (bounds == null || bounds.top < 0 || bounds.top > root.getHeight()) {
return false;
}
if (!isStatusChipCandidate(view)) {
return false;
}
Object parent = view.getParent();
while (parent instanceof View parentView && parentView != root) {
if (isStatusChipCandidate(parentView)) {
return false;
}
parent = parentView.getParent();
}
return true;
}
private boolean isStatusChipMetricCandidate(View root, View view) {
if (view == null || view == root || root == null) {
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 || width >= root.getWidth() / 2) {
return false;
}
Bounds bounds = ViewGeometry.boundsRelativeTo(root, view);
if (bounds == null || bounds.top < 0 || bounds.top > root.getHeight()) {
return false;
}
if (!isStatusChipCandidate(view)) {
return false;
}
Object parent = view.getParent();
while (parent instanceof View parentView && parentView != root) {
if (isStatusChipCandidate(parentView)) {
return false;
}
parent = parentView.getParent();
}
return true;
}
private boolean isStatusChipCandidate(View view) {
String className = view.getClass().getName().toLowerCase(java.util.Locale.ROOT);
String idName = resolveIdName(view).toLowerCase(java.util.Locale.ROOT);
if (className.contains("privacy") || idName.contains("privacy")) {
return false;
}
return className.contains("touchinterceptframelayout")
|| className.contains("ongoingactivity")
|| "ongoing_activity_capsule".equals(idName);
}
private boolean isStatusSnapshotCandidate(View view) {
if (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) {
return false;
}
String idName = resolveIdName(view);
if (idName.equals("battery") || containsAny(idName, "wifi", "mobile", "signal")) {
return true;
}
String className = view.getClass().getName();
if (className.contains("StatusBarIcon")
|| className.contains("StatusBarWifi")
|| className.contains("StatusBarMobile")
|| className.contains("StatusBarSignal")
|| className.contains("BatteryMeterView")) {
return true;
}
return view instanceof ImageView && idName.isEmpty();
}
private boolean isAtomicStatusSource(View view) {
if (view instanceof ImageView) {
return true;
}
String idName = resolveIdName(view);
String className = view.getClass().getName();
return idName.equals("battery")
|| containsAny(idName, "wifi", "mobile", "signal")
|| className.contains("StatusBarWifi")
|| className.contains("StatusBarMobile")
|| className.contains("StatusBarSignal")
|| className.contains("BatteryMeterView");
}
private boolean isNotificationSnapshotCandidate(View view) {
if (view instanceof TextView) {
return false;
}
if (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 || resolveIdName(view).equals("status_bar_dot")) {
return false;
}
String className = view.getClass().getName();
return className.contains("StatusBarIconView")
|| className.contains("IconView")
|| view instanceof ImageView;
}
private boolean containsAny(String value, String... needles) {
String lowerValue = value.toLowerCase(java.util.Locale.ROOT);
for (String needle : needles) {
if (lowerValue.contains(needle)) {
return true;
}
}
return false;
}
private boolean isDescendantOf(View child, View ancestor) {
if (child == null || ancestor == null) {
return false;
}
View current = child;
while (current != null) {
if (current == ancestor) {
return true;
}
Object parent = current.getParent();
current = parent instanceof View ? (View) parent : null;
}
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;
}
private static int shallowTreeSignature(View view) {
return shallowTreeSignatureExcluding(view, null);
}
private static int shallowTreeSignatureExcluding(View view, View excluded) {
if (view == null) {
return 0;
}
if (view == excluded) {
return 0;
}
int result = viewSignature(view);
if (view instanceof ViewGroup group) {
int includedChildren = 0;
for (int i = 0; i < group.getChildCount(); i++) {
View child = group.getChildAt(i);
if (child == excluded) {
continue;
}
includedChildren++;
result = 31 * result + viewSignature(child);
}
result = 31 * result + includedChildren;
}
return result;
}
private static int notificationRootSignature(View view) {
if (view == null) {
return 0;
}
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 + Float.floatToIntBits(view.getAlpha());
if (view instanceof ViewGroup group) {
result = 31 * result + group.getChildCount();
for (int i = 0; i < group.getChildCount(); i++) {
View child = group.getChildAt(i);
result = 31 * result + System.identityHashCode(child);
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<SnapshotSource> sources) {
if (sources == null || sources.isEmpty()) {
return 0;
}
int result = 17;
result = 31 * result + sources.size();
for (SnapshotSource source : sources) {
result = 31 * result + source.mode().ordinal();
result = 31 * result + viewTreeSignature(source.view());
}
return result;
}
private int chipSourceSignature(View root, CollectedIcons previousIcons) {
int result = 17;
boolean hasKnownChipSource = false;
if (previousIcons != null) {
if (previousIcons.statusChips != null && !previousIcons.statusChips.isEmpty()) {
hasKnownChipSource = true;
result = 31 * result + chipSourceListSignature(previousIcons.statusChips);
}
if (previousIcons.statusChipMetrics != null && !previousIcons.statusChipMetrics.isEmpty()) {
hasKnownChipSource = true;
result = 31 * result + chipSourceListSignature(previousIcons.statusChipMetrics);
}
}
if (!hasKnownChipSource) {
result = 31 * result + statusChipCandidateSignature(root);
}
return result;
}
private int statusChipCandidateSignature(View root) {
if (root == null) {
return 0;
}
int result = 17;
ArrayDeque<View> queue = new ArrayDeque<>();
queue.add(root);
while (!queue.isEmpty()) {
View view = queue.removeFirst();
if (view != root && isStatusChipCandidate(view)) {
result = 31 * result + chipViewLayoutSignature(view);
}
if (view instanceof ViewGroup group) {
for (int i = 0; i < group.getChildCount(); i++) {
queue.addLast(group.getChildAt(i));
}
}
}
return result;
}
private static int chipSourceListSignature(ArrayList<SnapshotSource> sources) {
if (sources == null || sources.isEmpty()) {
return 0;
}
int result = 17;
result = 31 * result + sources.size();
for (SnapshotSource source : sources) {
result = 31 * result + source.mode().ordinal();
result = 31 * result + chipViewLayoutSignature(source.view());
}
return result;
}
private static int chipViewLayoutSignature(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 + Float.floatToIntBits(view.getAlpha());
result = 31 * result + view.getTop();
result = 31 * result + width;
result = 31 * result + height;
result = 31 * result + Math.round(view.getTranslationY());
result = 31 * result + view.getPaddingLeft();
result = 31 * result + view.getPaddingRight();
result = 31 * result + view.getPaddingTop();
result = 31 * result + view.getPaddingBottom();
return result;
}
private static int viewSignature(View view) {
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 + Float.floatToIntBits(view.getAlpha());
result = 31 * result + view.getLeft();
result = 31 * result + view.getTop();
result = 31 * result + view.getRight();
result = 31 * result + view.getBottom();
result = 31 * result + view.getMeasuredWidth();
result = 31 * result + view.getMeasuredHeight();
result = 31 * result + view.getScrollX();
result = 31 * result + view.getScrollY();
result = 31 * result + Math.round(view.getTranslationX());
result = 31 * result + Math.round(view.getTranslationY());
result = 31 * result + view.getPaddingLeft();
result = 31 * result + view.getPaddingRight();
result = 31 * result + view.getPaddingTop();
result = 31 * result + view.getPaddingBottom();
CharSequence contentDescription = view.getContentDescription();
result = 31 * result + (contentDescription != null ? contentDescription.toString().hashCode() : 0);
result = 31 * result + java.util.Arrays.hashCode(view.getDrawableState());
if (view instanceof TextView textView) {
CharSequence text = textView.getText();
result = 31 * result + (text != null ? text.toString().hashCode() : 0);
result = 31 * result + textView.getCurrentTextColor();
result = 31 * result + Float.floatToIntBits(textView.getTextSize());
result = 31 * result + textView.getGravity();
result = 31 * result + textView.getBaseline();
result = 31 * result + (textView.getTypeface() != null
? System.identityHashCode(textView.getTypeface())
: 0);
}
if (view instanceof ImageView imageView) {
result = 31 * result + drawableSignature(imageView.getDrawable());
}
return result;
}
private static int drawableSignature(Drawable drawable) {
if (drawable == null) {
return 0;
}
int result = 17;
result = 31 * result + System.identityHashCode(drawable);
result = 31 * result + drawable.getLevel();
result = 31 * result + drawable.getAlpha();
result = 31 * result + java.util.Arrays.hashCode(drawable.getState());
Rect bounds = drawable.getBounds();
result = 31 * result + bounds.left;
result = 31 * result + bounds.top;
result = 31 * result + bounds.right;
result = 31 * result + bounds.bottom;
return result;
}
private String resolveIdName(View view) {
if (view.getId() == View.NO_ID) {
return "";
}
try {
return view.getResources().getResourceEntryName(view.getId());
} catch (Resources.NotFoundException ignored) {
return "";
}
}
private int clockTimeSignature(SbtSettings settings) {
if (settings == null || !settings.clockEnabledUnlocked) {
return 0;
}
if (!settings.clockShowSeconds
&& !settings.clockShowDatePrefix
&& !settings.clockCustomFormatEnabled) {
return 0;
}
long now = System.currentTimeMillis();
boolean seconds = settings.clockShowSeconds
|| (settings.clockCustomFormatEnabled
&& settings.clockCustomFormat != null
&& settings.clockCustomFormat.contains("s"));
long divisor = seconds ? 1_000L : 60_000L;
return Long.hashCode(now / divisor);
}
private int layoutSettingsSignature(SbtSettings settings) {
if (settings == null) {
return 0;
}
return java.util.Objects.hash(
settings.clockEnabled,
settings.clockEnabledUnlocked,
settings.clockPosition,
settings.clockHorizontalOffsetPx,
settings.clockMiddleCutoutSide,
settings.clockMiddleAutoNearestSide,
settings.clockMiddleCutoutGapPx,
settings.clockVerticalOffsetPx,
settings.clockShowSeconds,
settings.clockShowDatePrefix,
settings.clockCustomFormatEnabled,
settings.clockCustomFormat,
settings.clockIgnoreUnderDisplayCameras,
settings.layoutPaddingCutoutPx,
settings.layoutPaddingLeftPx,
settings.layoutPaddingRightPx,
settings.layoutItemPaddingPx,
settings.layoutPaddingLeftAddStock,
settings.layoutPaddingRightAddStock,
settings.layoutIgnoreUnderDisplayCameras,
settings.layoutItemOrder,
settings.layoutChipPosition,
settings.layoutChipEnabledUnlocked,
settings.layoutChipMiddleSide,
settings.layoutChipMiddleAutoNearestSide,
settings.layoutChipVerticalOffsetPx,
settings.layoutNotifPosition,
settings.layoutNotifSortOrder,
settings.layoutNotifShowDotIfTruncated,
settings.layoutNotifEnabledUnlocked,
settings.layoutNotifMiddleSide,
settings.layoutNotifMiddleAutoNearestSide,
settings.layoutNotifVerticalOffsetPx,
settings.layoutStatusPosition,
settings.layoutStatusEnabledUnlocked,
settings.layoutStatusShowDotIfTruncated,
settings.layoutStatusMiddleSide,
settings.layoutStatusMiddleAutoNearestSide,
settings.layoutStatusVerticalOffsetPx,
settings.statusChipsHideAll,
settings.statusChipsHideMediaUnlocked,
settings.statusChipsHideNavUnlocked,
settings.statusChipsHideCallUnlocked);
}
}
@@ -0,0 +1,54 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.widget.TextView;
final class UnlockedVerticalOffsetScaler {
private int baselineNotificationIconSizePx;
private int baselineStatusIconHeightPx;
private int baselineChipHeightPx;
private float baselineClockTextSizePx;
int notification(int offsetPx, int currentIconHeightPx) {
baselineNotificationIconSizePx = updateBaseline(
baselineNotificationIconSizePx,
currentIconHeightPx);
return scaledOffset(offsetPx, currentIconHeightPx, baselineNotificationIconSizePx);
}
int status(int offsetPx, int currentIconHeightPx) {
baselineStatusIconHeightPx = updateBaseline(
baselineStatusIconHeightPx,
currentIconHeightPx);
return scaledOffset(offsetPx, currentIconHeightPx, baselineStatusIconHeightPx);
}
int chip(int offsetPx, int currentChipHeightPx) {
baselineChipHeightPx = updateBaseline(baselineChipHeightPx, currentChipHeightPx);
return scaledOffset(offsetPx, currentChipHeightPx, baselineChipHeightPx);
}
int clock(int offsetPx, TextView source) {
if (offsetPx == 0 || source == null || source.getTextSize() <= 0f) {
return offsetPx;
}
float textSize = source.getTextSize();
if (baselineClockTextSizePx <= 0f || textSize < baselineClockTextSizePx) {
baselineClockTextSizePx = textSize;
}
return Math.round(offsetPx * textSize / baselineClockTextSizePx);
}
private int updateBaseline(int baselinePx, int currentPx) {
if (currentPx <= 0) {
return baselinePx;
}
return baselinePx <= 0 || currentPx < baselinePx ? currentPx : baselinePx;
}
private int scaledOffset(int offsetPx, int currentPx, int baselinePx) {
if (offsetPx == 0 || currentPx <= 0 || baselinePx <= 0) {
return offsetPx;
}
return Math.round((float) offsetPx * currentPx / baselinePx);
}
}
@@ -0,0 +1,92 @@
package se.ajpanton.statusbartweak.runtime.render;
import android.graphics.Rect;
import android.view.DisplayCutout;
import android.view.View;
import android.view.WindowInsets;
import java.util.List;
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds;
final class ViewGeometry {
private ViewGeometry() {
}
static Bounds boundsRelativeTo(View root, View view) {
if (view == null) {
return null;
}
int left = 0;
int top = 0;
View current = view;
while (current != null && current != root) {
left += current.getLeft() - current.getScrollX() + Math.round(current.getTranslationX());
top += current.getTop() - current.getScrollY() + Math.round(current.getTranslationY());
Object parent = current.getParent();
current = parent instanceof View ? (View) parent : null;
}
if (current != root) {
return null;
}
int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth();
int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight();
return new Bounds(left, top, Math.max(0, width), Math.max(0, height));
}
static Rect middleCutout(View root) {
if (root == null || root.getWidth() <= 0) {
return null;
}
WindowInsets insets = root.getRootWindowInsets();
DisplayCutout cutout = insets != null ? insets.getDisplayCutout() : null;
List<Rect> rects = cutout != null ? cutout.getBoundingRects() : null;
if (rects == null || rects.isEmpty()) {
return null;
}
int rootWidth = root.getWidth();
for (Rect rect : rects) {
Rect localRect = localCutoutRect(root, rect);
if (localRect == null || localRect.isEmpty()) {
continue;
}
if (localRect.left <= 0 || localRect.right >= rootWidth) {
continue;
}
return new Rect(
Math.max(0, localRect.left),
Math.max(0, localRect.top),
Math.min(rootWidth, localRect.right),
Math.min(root.getHeight(), localRect.bottom));
}
return null;
}
private static Rect localCutoutRect(View root, Rect rect) {
if (root == null || rect == null || rect.isEmpty()) {
return null;
}
int[] location = new int[2];
root.getLocationOnScreen(location);
Rect local = new Rect(
rect.left - location[0],
rect.top - location[1],
rect.right - location[0],
rect.bottom - location[1]);
if (intersectsRoot(root, local)) {
return local;
}
if (intersectsRoot(root, rect)) {
return new Rect(rect);
}
return local;
}
private static boolean intersectsRoot(View root, Rect rect) {
return rect != null
&& rect.right > 0
&& rect.bottom > 0
&& rect.left < root.getWidth()
&& rect.top < root.getHeight();
}
}
@@ -0,0 +1,79 @@
package se.ajpanton.statusbartweak.runtime.settings;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
/**
* Small process-local cache for hot SystemUI hooks.
*
* Runtime hooks can run multiple times per frame. Reading settings through the
* provider in those paths is unnecessarily expensive; settings changes already
* send ACTION_SETTINGS_CHANGED, so we invalidate and reload on demand.
*/
public final class RuntimeSettingsCache {
private static final Object LOCK = new Object();
private static SbtSettings cachedSettings;
private static boolean receiverRegistered;
private RuntimeSettingsCache() {
}
public static SbtSettings get(Context context) {
if (context == null) {
return cachedSettings != null ? cachedSettings : SbtSettings.defaults();
}
registerReceiverIfNeeded(context);
SbtSettings settings = cachedSettings;
if (settings != null) {
return settings;
}
synchronized (LOCK) {
if (cachedSettings == null) {
cachedSettings = SbtSettings.from(context);
}
return cachedSettings != null ? cachedSettings : SbtSettings.defaults();
}
}
public static void invalidate() {
synchronized (LOCK) {
cachedSettings = null;
}
}
private static void registerReceiverIfNeeded(Context context) {
if (receiverRegistered || context == null) {
return;
}
synchronized (LOCK) {
if (receiverRegistered) {
return;
}
Context appContext = context.getApplicationContext();
if (appContext == null) {
appContext = context;
}
BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
invalidate();
}
};
IntentFilter filter = new IntentFilter(SbtSettings.ACTION_SETTINGS_CHANGED);
filter.addAction(Intent.ACTION_USER_UNLOCKED);
if (Build.VERSION.SDK_INT >= 33) {
appContext.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED);
} else {
appContext.registerReceiver(receiver, filter);
}
receiverRegistered = true;
}
}
}
@@ -0,0 +1,16 @@
package se.ajpanton.statusbartweak.shell.debug;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
public final class DebugBootReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (Intent.ACTION_BOOT_COMPLETED.equals(action)
|| Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)) {
DebugNotifications.applyPending(context);
}
}
}
@@ -0,0 +1,28 @@
package se.ajpanton.statusbartweak.shell.debug;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
public final class DebugNotificationDeleteReceiver extends BroadcastReceiver {
private static final String ACTION = "se.ajpanton.statusbartweak.DEBUG_DELETE";
private static final String EXTRA_NUMBER = "number";
static Intent newIntent(Context context, int number) {
Intent intent = new Intent(context, DebugNotificationDeleteReceiver.class);
intent.setAction(ACTION);
intent.putExtra(EXTRA_NUMBER, number);
return intent;
}
@Override
public void onReceive(Context context, Intent intent) {
if (!ACTION.equals(intent.getAction())) {
return;
}
int number = intent.getIntExtra(EXTRA_NUMBER, 0);
if (number > 0 && number <= DebugNotifications.getDesiredCount(context)) {
DebugNotifications.applyPending(context);
}
}
}
@@ -0,0 +1,440 @@
package se.ajpanton.statusbartweak.shell.debug;
import android.Manifest;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.Icon;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.PowerManager;
import android.os.SystemClock;
import android.service.notification.StatusBarNotification;
import java.util.Locale;
import se.ajpanton.statusbartweak.shell.ui.MainActivity;
public final class DebugNotifications {
private static final String PREFS = "sbt_debug_notifications";
private static final String KEY_DESIRED = "debug_icon_count";
private static final String KEY_CYCLE_ENABLED = "debug_icon_cycle_enabled";
private static final String KEY_CYCLE_CURRENT = "debug_icon_cycle_current";
private static final String KEY_CYCLE_DIRECTION = "debug_icon_cycle_direction";
private static final String KEY_BOOT_EPOCH = "debug_boot_epoch";
private static final String KEY_RECOVERY_USED = "debug_autogroup_recovery_used";
private static final int MIN_COUNT = 0;
private static final int MAX_COUNT = 50;
private static final int DEBUG_ID_BASE = 10_000;
private static final long AUTO_GROUP_CHECK_DELAY_MS = 3_500L;
private static final long BOOT_EPOCH_TOLERANCE_MS = 5_000L;
private static final long CYCLE_STEP_MS = 2_000L;
private static final String CHANNEL_ID = "sbt_debug_icons";
private static final String CHANNEL_NAME = "Debug Icons";
private static final String GROUP_PREFIX = "sbt_debug_";
private static final String SHORTCUT_PREFIX = "sbt_debug_shortcut_";
private static final String EXTRA_DEBUG_NOTIFICATION = "sbt_debug_notification";
private static final String EXTRA_DEBUG_NUMBER = "sbt_debug_number";
private static final Handler RECOVERY_HANDLER = new Handler(Looper.getMainLooper());
private static final Handler CYCLE_HANDLER = new Handler(Looper.getMainLooper());
private static Runnable recoveryRunnable;
private static Runnable cycleRunnable;
private static PowerManager.WakeLock cycleWakeLock;
private static boolean recoveryInProgress;
private DebugNotifications() {
}
public static int getDesiredCount(Context context) {
return prefs(context).getInt(KEY_DESIRED, 0);
}
public static int clampCount(int value) {
return Math.min(MAX_COUNT, Math.max(MIN_COUNT, value));
}
public static boolean isCycleEnabled(Context context) {
return prefs(context).getBoolean(KEY_CYCLE_ENABLED, false);
}
public static void setCycleEnabled(Context context, boolean enabled) {
SharedPreferences prefs = prefs(context);
SharedPreferences.Editor edit = prefs.edit().putBoolean(KEY_CYCLE_ENABLED, enabled);
if (enabled) {
edit.putInt(KEY_CYCLE_CURRENT, 0);
edit.putInt(KEY_CYCLE_DIRECTION, 1);
}
edit.putBoolean(KEY_RECOVERY_USED, false);
edit.apply();
applyPending(context);
}
public static void setDesiredCount(Context context, int desiredCount) {
prefs(context)
.edit()
.putInt(KEY_DESIRED, clampCount(desiredCount))
.putBoolean(KEY_RECOVERY_USED, false)
.apply();
applyPending(context);
}
public static boolean hasPostNotificationsPermission(Context context) {
return context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED;
}
public static void applyPending(Context context) {
if (!hasPostNotificationsPermission(context)) {
return;
}
Context appContext = context.getApplicationContext();
SharedPreferences prefs = prefs(appContext);
ensureBootEpoch(prefs);
int configured = clampCount(prefs.getInt(KEY_DESIRED, 0));
int desired = resolveDesiredVisibleCount(prefs, configured);
NotificationManager nm = appContext.getSystemService(NotificationManager.class);
ensureChannel(nm);
cancelDebugNotificationsAbove(appContext, nm, desired);
cleanupAutoGroupSummaries(appContext, nm);
for (int i = 1; i <= desired; i++) {
nm.notify(DEBUG_ID_BASE + i, buildDebugNotification(appContext, i));
}
if (desired > 0) {
scheduleAutoGroupRecovery(appContext);
} else {
cancelAutoGroupRecovery();
}
syncCycleScheduler(appContext, prefs, configured);
}
private static SharedPreferences prefs(Context context) {
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
private static void ensureChannel(NotificationManager nm) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription("Debug notification icons for StatusBarTweak");
channel.setShowBadge(false);
channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC);
nm.createNotificationChannel(channel);
}
private static Notification buildDebugNotification(Context context, int number) {
Intent openIntent = MainActivity.newOpenDebugIntent(context);
Intent deleteIntent = DebugNotificationDeleteReceiver.newIntent(context, number);
int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE;
Bundle extras = new Bundle();
extras.putBoolean(EXTRA_DEBUG_NOTIFICATION, true);
extras.putInt(EXTRA_DEBUG_NUMBER, number);
Notification notification = new Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(createNumberIcon(context, number))
.setContentTitle("Debug icon " + number)
.setContentText("StatusBarTweak")
.setShowWhen(false)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(Notification.CATEGORY_STATUS)
.setGroup(GROUP_PREFIX + number)
.setSortKey(String.format(Locale.ROOT, "%02d", number))
.setShortcutId(SHORTCUT_PREFIX + number)
.setContentIntent(PendingIntent.getActivity(context, 0, openIntent, flags))
.setDeleteIntent(PendingIntent.getBroadcast(context, number, deleteIntent, flags))
.addExtras(extras)
.build();
notification.flags |= Notification.FLAG_NO_CLEAR | Notification.FLAG_ONGOING_EVENT;
return notification;
}
private static Icon createNumberIcon(Context context, int number) {
int sizePx = dpToPx(context, 48);
Bitmap bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
Paint bg = new Paint(Paint.ANTI_ALIAS_FLAG);
float hue = (number * 37f) % 360f;
bg.setColor(Color.HSVToColor(new float[]{hue, 0.6f, 0.9f}));
canvas.drawRect(0, 0, sizePx, sizePx, bg);
Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
text.setColor(Color.WHITE);
text.setTextAlign(Paint.Align.CENTER);
text.setFakeBoldText(true);
String label = String.valueOf(number);
text.setTextSize(sizePx * (label.length() >= 2 ? 0.45f : 0.6f));
Rect bounds = new Rect();
text.getTextBounds(label, 0, label.length(), bounds);
canvas.drawText(label, sizePx / 2f, sizePx / 2f - bounds.exactCenterY(), text);
return Icon.createWithBitmap(bitmap);
}
private static void cancelDebugNotificationsAbove(
Context context,
NotificationManager nm,
int desired
) {
for (StatusBarNotification sbn : nm.getActiveNotifications()) {
if (!context.getPackageName().equals(sbn.getPackageName())) {
continue;
}
int debugNumber = sbn.getId() - DEBUG_ID_BASE;
if (debugNumber > desired && debugNumber >= 1 && debugNumber <= MAX_COUNT) {
cancel(nm, sbn);
}
}
}
private static void cleanupAutoGroupSummaries(Context context, NotificationManager nm) {
for (StatusBarNotification sbn : nm.getActiveNotifications()) {
if (!context.getPackageName().equals(sbn.getPackageName()) || isDebugId(sbn.getId())) {
continue;
}
Notification notification = sbn.getNotification();
if (notification == null || isDebugNotification(notification)) {
continue;
}
String group = notification.getGroup();
if (group == null || !group.startsWith(GROUP_PREFIX)) {
cancel(nm, sbn);
}
}
}
private static void scheduleAutoGroupRecovery(Context context) {
SharedPreferences prefs = prefs(context);
ensureBootEpoch(prefs);
if (prefs.getBoolean(KEY_RECOVERY_USED, false) || recoveryInProgress) {
return;
}
Context appContext = context.getApplicationContext();
cancelAutoGroupRecovery();
recoveryRunnable = () -> runAutoGroupRecovery(appContext);
RECOVERY_HANDLER.postDelayed(recoveryRunnable, AUTO_GROUP_CHECK_DELAY_MS);
}
private static void runAutoGroupRecovery(Context appContext) {
if (recoveryInProgress || appContext == null || !hasPostNotificationsPermission(appContext)) {
return;
}
SharedPreferences prefs = prefs(appContext);
ensureBootEpoch(prefs);
if (prefs.getBoolean(KEY_RECOVERY_USED, false)) {
return;
}
int configured = clampCount(prefs.getInt(KEY_DESIRED, 0));
int desired = resolveDesiredVisibleCount(prefs, configured);
if (desired <= 0) {
return;
}
NotificationManager nm = appContext.getSystemService(NotificationManager.class);
if (nm == null || !hasAutoGroupSummary(appContext, nm)) {
return;
}
recoveryInProgress = true;
try {
prefs.edit().putBoolean(KEY_RECOVERY_USED, true).apply();
cancelDebugNotificationsAndSummaries(appContext, nm);
for (int i = 1; i <= desired; i++) {
nm.notify(DEBUG_ID_BASE + i, buildDebugNotification(appContext, i));
}
} finally {
recoveryInProgress = false;
}
}
private static void cancelAutoGroupRecovery() {
if (recoveryRunnable != null) {
RECOVERY_HANDLER.removeCallbacks(recoveryRunnable);
recoveryRunnable = null;
}
}
private static boolean hasAutoGroupSummary(Context context, NotificationManager nm) {
for (StatusBarNotification sbn : nm.getActiveNotifications()) {
if (!context.getPackageName().equals(sbn.getPackageName()) || isDebugId(sbn.getId())) {
continue;
}
Notification notification = sbn.getNotification();
if (notification == null || isDebugNotification(notification)) {
continue;
}
String group = notification.getGroup();
if (group == null || !group.startsWith(GROUP_PREFIX)) {
return true;
}
}
return false;
}
private static void cancelDebugNotificationsAndSummaries(Context context, NotificationManager nm) {
for (StatusBarNotification sbn : nm.getActiveNotifications()) {
if (!context.getPackageName().equals(sbn.getPackageName())) {
continue;
}
Notification notification = sbn.getNotification();
boolean debugSummary = notification != null
&& !isDebugNotification(notification)
&& (notification.getGroup() == null
|| !notification.getGroup().startsWith(GROUP_PREFIX));
if (isDebugId(sbn.getId()) || debugSummary) {
cancel(nm, sbn);
}
}
}
private static void ensureBootEpoch(SharedPreferences prefs) {
long bootEpoch = System.currentTimeMillis() - SystemClock.elapsedRealtime();
long stored = prefs.getLong(KEY_BOOT_EPOCH, -1L);
if (stored <= 0L || Math.abs(stored - bootEpoch) > BOOT_EPOCH_TOLERANCE_MS) {
prefs.edit()
.putLong(KEY_BOOT_EPOCH, bootEpoch)
.putBoolean(KEY_RECOVERY_USED, false)
.apply();
}
}
private static boolean isDebugNotification(Notification notification) {
Bundle extras = notification.extras;
return extras != null && extras.getBoolean(EXTRA_DEBUG_NOTIFICATION, false);
}
private static boolean isDebugId(int id) {
int debugNumber = id - DEBUG_ID_BASE;
return debugNumber >= 1 && debugNumber <= MAX_COUNT;
}
private static void cancel(NotificationManager nm, StatusBarNotification sbn) {
if (sbn.getTag() != null) {
nm.cancel(sbn.getTag(), sbn.getId());
} else {
nm.cancel(sbn.getId());
}
}
private static int resolveDesiredVisibleCount(SharedPreferences prefs, int configuredCount) {
if (!prefs.getBoolean(KEY_CYCLE_ENABLED, false)) {
return configuredCount;
}
int current = clampCount(prefs.getInt(KEY_CYCLE_CURRENT, 0));
if (current > configuredCount) {
current = configuredCount;
prefs.edit().putInt(KEY_CYCLE_CURRENT, current).apply();
}
return current;
}
private static void syncCycleScheduler(
Context appContext,
SharedPreferences prefs,
int configuredCount
) {
if (!prefs.getBoolean(KEY_CYCLE_ENABLED, false) || configuredCount <= 0) {
stopCycleScheduler();
return;
}
startCycleScheduler(appContext);
}
private static void startCycleScheduler(Context appContext) {
if (cycleRunnable != null) {
return;
}
acquireCycleWakeLock(appContext);
cycleRunnable = new Runnable() {
@Override
public void run() {
if (!advanceCycleAndApply(appContext)) {
stopCycleScheduler();
return;
}
CYCLE_HANDLER.postDelayed(this, CYCLE_STEP_MS);
}
};
CYCLE_HANDLER.postDelayed(cycleRunnable, CYCLE_STEP_MS);
}
private static void stopCycleScheduler() {
if (cycleRunnable != null) {
CYCLE_HANDLER.removeCallbacks(cycleRunnable);
cycleRunnable = null;
}
releaseCycleWakeLock();
}
private static boolean advanceCycleAndApply(Context appContext) {
SharedPreferences prefs = prefs(appContext);
if (!prefs.getBoolean(KEY_CYCLE_ENABLED, false)) {
return false;
}
int configured = clampCount(prefs.getInt(KEY_DESIRED, 0));
if (configured <= 0) {
prefs.edit()
.putInt(KEY_CYCLE_CURRENT, 0)
.putInt(KEY_CYCLE_DIRECTION, 1)
.apply();
applyPending(appContext);
return false;
}
int current = Math.min(clampCount(prefs.getInt(KEY_CYCLE_CURRENT, 0)), configured);
int direction = prefs.getInt(KEY_CYCLE_DIRECTION, 1);
direction = direction == -1 ? -1 : 1;
current += direction;
if (current >= configured) {
current = configured;
direction = -1;
} else if (current <= 0) {
current = 0;
direction = 1;
}
prefs.edit()
.putInt(KEY_CYCLE_CURRENT, current)
.putInt(KEY_CYCLE_DIRECTION, direction)
.apply();
applyPending(appContext);
return true;
}
private static void acquireCycleWakeLock(Context context) {
if (cycleWakeLock == null) {
PowerManager pm = context.getSystemService(PowerManager.class);
cycleWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
"StatusBarTweak:DebugCycle");
cycleWakeLock.setReferenceCounted(false);
}
if (!cycleWakeLock.isHeld()) {
cycleWakeLock.acquire();
}
}
private static void releaseCycleWakeLock() {
if (cycleWakeLock != null && cycleWakeLock.isHeld()) {
cycleWakeLock.release();
}
}
private static int dpToPx(Context context, int dp) {
return (int) (dp * context.getResources().getDisplayMetrics().density + 0.5f);
}
}
@@ -0,0 +1,478 @@
package se.ajpanton.statusbartweak.shell.settings;
import android.content.SharedPreferences;
import android.util.Base64;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
public final class AppIconRules {
private static final Pattern PACKAGE_NAME_PATTERN =
Pattern.compile("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$");
public static final String PREF_KEY_BLOCKED_MODES = "app_icon_blocked_modes";
public static final String PREF_KEY_EXCEPTION_RULES = "app_icon_exception_rules";
public static final String PREF_KEY_SEEN_SESSION = "app_icon_seen_session";
public static final String EXTRA_PACKAGE = "package";
public static final String EXTRA_FIRST_SEEN_MS = "first_seen_ms";
public static final int MODE_AOD = 1;
public static final int MODE_LOCK = 1 << 1;
public static final int MODE_UNLOCK = 1 << 2;
private static final String MODE_NAME_AOD = "AOD";
private static final String MODE_NAME_LOCK = "LOCK";
private static final String MODE_NAME_UNLOCK = "UNLOCK";
private static final String MODE_NAME_UNKNOWN = "UNKNOWN";
private AppIconRules() {
}
public static int modeMask(String mode) {
String normalized = normalizeModeName(mode);
if (MODE_NAME_AOD.equals(normalized)) {
return MODE_AOD;
}
if (MODE_NAME_LOCK.equals(normalized)) {
return MODE_LOCK;
}
if (MODE_NAME_UNLOCK.equals(normalized)) {
return MODE_UNLOCK;
}
return 0;
}
public static boolean isValidPackageName(String pkg) {
if (pkg == null || pkg.isEmpty()) {
return false;
}
return PACKAGE_NAME_PATTERN.matcher(pkg).matches();
}
public static Map<String, Integer> loadBlockedModes(SharedPreferences prefs) {
if (prefs == null) {
return new HashMap<>();
}
Set<String> raw = prefs.getStringSet(PREF_KEY_BLOCKED_MODES, Collections.emptySet());
return parseBlockedModes(raw);
}
public static void saveBlockedModes(SharedPreferences prefs, Map<String, Integer> map) {
if (prefs == null) {
return;
}
Set<String> encoded = encodeBlockedModes(map);
prefs.edit().putStringSet(PREF_KEY_BLOCKED_MODES, encoded).apply();
}
public static Map<String, Long> loadSeenSession(SharedPreferences prefs) {
if (prefs == null) {
return new HashMap<>();
}
Set<String> raw = prefs.getStringSet(PREF_KEY_SEEN_SESSION, Collections.emptySet());
return parseSeenSession(raw);
}
public static Map<String, ArrayList<ExceptionRule>> loadExceptionRules(SharedPreferences prefs) {
if (prefs == null) {
return new HashMap<>();
}
Set<String> raw = prefs.getStringSet(PREF_KEY_EXCEPTION_RULES, Collections.emptySet());
return parseExceptionRules(raw);
}
public static void saveExceptionRules(SharedPreferences prefs, Map<String, ArrayList<ExceptionRule>> map) {
if (prefs == null) {
return;
}
Set<String> encoded = encodeExceptionRules(map);
prefs.edit().putStringSet(PREF_KEY_EXCEPTION_RULES, encoded).apply();
}
public static void saveSeenSession(SharedPreferences prefs, Map<String, Long> map) {
if (prefs == null) {
return;
}
Set<String> encoded = encodeSeenSession(map);
prefs.edit().putStringSet(PREF_KEY_SEEN_SESSION, encoded).apply();
}
public static void clearSeenSession(SharedPreferences prefs) {
if (prefs == null) {
return;
}
prefs.edit().remove(PREF_KEY_SEEN_SESSION).apply();
}
public static void noteSeen(Map<String, Long> seen, String pkg, long firstSeenMs) {
if (seen == null || !isValidPackageName(pkg)) {
return;
}
Long existing = seen.get(pkg);
if (existing == null) {
seen.put(pkg, firstSeenMs);
return;
}
if (firstSeenMs > 0 && firstSeenMs < existing) {
seen.put(pkg, firstSeenMs);
}
}
public static boolean isBlocked(Map<String, Integer> masks, String pkg, String mode) {
if (masks == null || !isValidPackageName(pkg)) {
return false;
}
Integer mask = masks.get(pkg);
if (mask == null) {
return false;
}
int modeMask = modeMask(mode);
if (modeMask == 0) {
return false;
}
return (mask & modeMask) != 0;
}
public static void setModeBlocked(Map<String, Integer> masks,
String pkg,
boolean blockAod,
boolean blockLock,
boolean blockUnlock) {
if (masks == null || !isValidPackageName(pkg)) {
return;
}
int mask = 0;
if (blockAod) {
mask |= MODE_AOD;
}
if (blockLock) {
mask |= MODE_LOCK;
}
if (blockUnlock) {
mask |= MODE_UNLOCK;
}
if (mask == 0) {
masks.remove(pkg);
return;
}
masks.put(pkg, mask);
}
public static ArrayList<ExceptionRule> copyExceptionRules(List<ExceptionRule> source) {
ArrayList<ExceptionRule> out = new ArrayList<>();
if (source == null) {
return out;
}
for (ExceptionRule rule : source) {
if (rule == null || rule.regex == null) {
continue;
}
String regex = rule.regex.trim();
if (regex.isEmpty()) {
continue;
}
out.add(new ExceptionRule(regex, sanitizeModeMask(rule.modeMask)));
}
return out;
}
public static void setExceptionRules(Map<String, ArrayList<ExceptionRule>> rulesByPackage,
String pkg,
List<ExceptionRule> rules) {
if (rulesByPackage == null || !isValidPackageName(pkg)) {
return;
}
ArrayList<ExceptionRule> copy = copyExceptionRules(rules);
if (copy.isEmpty()) {
rulesByPackage.remove(pkg);
return;
}
rulesByPackage.put(pkg, copy);
}
public static Map<String, Integer> parseBlockedModes(Set<String> rawSet) {
Map<String, Integer> out = new HashMap<>();
if (rawSet == null || rawSet.isEmpty()) {
return out;
}
for (String entry : new HashSet<>(rawSet)) {
if (entry == null || entry.isEmpty()) {
continue;
}
String[] parts = entry.split("\\|", -1);
if (parts.length != 4) {
continue;
}
String pkg = parts[0];
if (!isValidPackageName(pkg)) {
continue;
}
int mask = 0;
if ("1".equals(parts[1])) {
mask |= MODE_AOD;
}
if ("1".equals(parts[2])) {
mask |= MODE_LOCK;
}
if ("1".equals(parts[3])) {
mask |= MODE_UNLOCK;
}
if (mask != 0) {
out.put(pkg, mask);
}
}
return out;
}
public static Set<String> encodeBlockedModes(Map<String, Integer> map) {
Set<String> out = new HashSet<>();
if (map == null || map.isEmpty()) {
return out;
}
for (Map.Entry<String, Integer> e : map.entrySet()) {
String pkg = e.getKey();
Integer maskObj = e.getValue();
if (!isValidPackageName(pkg) || maskObj == null) {
continue;
}
int mask = maskObj;
if (mask == 0) {
continue;
}
String entry = pkg + "|"
+ (((mask & MODE_AOD) != 0) ? "1" : "0") + "|"
+ (((mask & MODE_LOCK) != 0) ? "1" : "0") + "|"
+ (((mask & MODE_UNLOCK) != 0) ? "1" : "0");
out.add(entry);
}
return out;
}
public static Map<String, ArrayList<ExceptionRule>> parseExceptionRules(Set<String> rawSet) {
Map<String, ArrayList<IndexedExceptionRule>> staged = new HashMap<>();
if (rawSet == null || rawSet.isEmpty()) {
return new HashMap<>();
}
for (String entry : new HashSet<>(rawSet)) {
if (entry == null || entry.isEmpty()) {
continue;
}
String[] parts = entry.split("\\|", 4);
if (parts.length != 4) {
continue;
}
String pkg = parts[0];
if (!isValidPackageName(pkg)) {
continue;
}
int index;
int modeMask;
try {
index = Integer.parseInt(parts[1]);
modeMask = Integer.parseInt(parts[2]);
} catch (NumberFormatException ignored) {
continue;
}
if (index < 0) {
continue;
}
modeMask = sanitizeModeMask(modeMask);
String regex;
try {
byte[] decoded = Base64.decode(parts[3], Base64.NO_WRAP);
regex = new String(decoded, StandardCharsets.UTF_8).trim();
} catch (Throwable ignored) {
continue;
}
if (regex.isEmpty()) {
continue;
}
staged.computeIfAbsent(pkg, key -> new ArrayList<>())
.add(new IndexedExceptionRule(index, new ExceptionRule(regex, modeMask)));
}
Map<String, ArrayList<ExceptionRule>> out = new HashMap<>();
for (Map.Entry<String, ArrayList<IndexedExceptionRule>> e : staged.entrySet()) {
ArrayList<IndexedExceptionRule> stagedList = e.getValue();
stagedList.sort((a, b) -> Integer.compare(a.index, b.index));
ArrayList<ExceptionRule> rules = new ArrayList<>(stagedList.size());
for (IndexedExceptionRule indexed : stagedList) {
rules.add(indexed.rule);
}
if (!rules.isEmpty()) {
out.put(e.getKey(), rules);
}
}
return out;
}
public static Set<String> encodeExceptionRules(Map<String, ArrayList<ExceptionRule>> map) {
Set<String> out = new HashSet<>();
if (map == null || map.isEmpty()) {
return out;
}
for (Map.Entry<String, ArrayList<ExceptionRule>> e : map.entrySet()) {
String pkg = e.getKey();
if (!isValidPackageName(pkg) || e.getValue() == null) {
continue;
}
ArrayList<ExceptionRule> rules = e.getValue();
for (int i = 0; i < rules.size(); i++) {
ExceptionRule rule = rules.get(i);
if (rule == null || rule.regex == null) {
continue;
}
String regex = rule.regex.trim();
if (regex.isEmpty()) {
continue;
}
String encodedRegex = Base64.encodeToString(
regex.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP);
int modeMask = sanitizeModeMask(rule.modeMask);
out.add(pkg + "|" + i + "|" + modeMask + "|" + encodedRegex);
}
}
return out;
}
private static int sanitizeModeMask(int mask) {
int allowed = MODE_AOD | MODE_LOCK | MODE_UNLOCK;
return mask & allowed;
}
private static String normalizeModeName(String mode) {
if (mode == null) {
return MODE_NAME_UNKNOWN;
}
String normalized = mode.trim().toUpperCase(Locale.ROOT);
if (normalized.isEmpty()) {
return MODE_NAME_UNKNOWN;
}
if (MODE_NAME_AOD.equals(normalized)) {
return MODE_NAME_AOD;
}
if (MODE_NAME_LOCK.equals(normalized) || "LOCKSCREEN".equals(normalized)) {
return MODE_NAME_LOCK;
}
if (MODE_NAME_UNLOCK.equals(normalized) || "UNLOCKED".equals(normalized)) {
return MODE_NAME_UNLOCK;
}
return MODE_NAME_UNKNOWN;
}
public static Map<String, Long> parseSeenSession(Set<String> rawSet) {
Map<String, Long> out = new HashMap<>();
if (rawSet == null || rawSet.isEmpty()) {
return out;
}
for (String entry : new HashSet<>(rawSet)) {
if (entry == null || entry.isEmpty()) {
continue;
}
String[] parts = entry.split("\\|", -1);
if (parts.length != 2) {
continue;
}
String pkg = parts[0];
if (!isValidPackageName(pkg)) {
continue;
}
long firstSeen;
try {
firstSeen = Long.parseLong(parts[1]);
} catch (NumberFormatException ignored) {
continue;
}
if (firstSeen <= 0L) {
continue;
}
Long existing = out.get(pkg);
if (existing == null || firstSeen < existing) {
out.put(pkg, firstSeen);
}
}
return out;
}
public static Set<String> encodeSeenSession(Map<String, Long> map) {
Set<String> out = new HashSet<>();
if (map == null || map.isEmpty()) {
return out;
}
for (Map.Entry<String, Long> e : map.entrySet()) {
String pkg = e.getKey();
Long firstSeenObj = e.getValue();
if (!isValidPackageName(pkg) || firstSeenObj == null) {
continue;
}
long firstSeen = firstSeenObj;
if (firstSeen <= 0L) {
continue;
}
out.add(pkg + "|" + firstSeen);
}
return out;
}
public static final class SeenApp {
public final String packageName;
public final long firstSeenMs;
public SeenApp(String packageName, long firstSeenMs) {
this.packageName = packageName;
this.firstSeenMs = firstSeenMs;
}
}
public static final class ExceptionRule {
public final String regex;
public final int modeMask;
public ExceptionRule(String regex, int modeMask) {
this.regex = regex;
this.modeMask = sanitizeModeMask(modeMask);
}
public boolean blocksMode(String mode) {
int modeBit = modeMask(mode);
if (modeBit == 0) {
return false;
}
return (modeMask & modeBit) != 0;
}
}
private static final class IndexedExceptionRule {
final int index;
final ExceptionRule rule;
IndexedExceptionRule(int index, ExceptionRule rule) {
this.index = index;
this.rule = rule;
}
}
public static List<SeenApp> sortSeenOldestFirst(Map<String, Long> seen) {
List<SeenApp> out = new ArrayList<>();
if (seen == null || seen.isEmpty()) {
return out;
}
for (Map.Entry<String, Long> e : seen.entrySet()) {
String pkg = e.getKey();
Long firstSeen = e.getValue();
if (!isValidPackageName(pkg) || firstSeen == null || firstSeen <= 0L) {
continue;
}
out.add(new SeenApp(pkg, firstSeen));
}
out.sort((a, b) -> Long.compare(a.firstSeenMs, b.firstSeenMs));
return out;
}
}
@@ -0,0 +1,245 @@
package se.ajpanton.statusbartweak.shell.settings;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Color;
import android.net.Uri;
import android.os.BatteryManager;
import android.os.Build;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
public final class BatteryBarStyle {
public static final String THRESHOLD_CHARGE_USE_DEFAULT = "@default_charge";
public static final class Threshold {
public final int percent;
public final String dischargeColor;
public final String chargeColor;
public Threshold(int percent, String dischargeColor, String chargeColor) {
this.percent = clampPercent(percent);
this.dischargeColor = normalizeColor(dischargeColor);
this.chargeColor = normalizeThresholdChargeColor(chargeColor);
}
}
private BatteryBarStyle() {
}
public static String normalizeColor(String raw) {
return ColourExpressionSupport.normaliseExpression(raw, true);
}
public static int parseColorOrDefault(String raw, int fallback) {
return ColourExpressionSupport.resolveColor(raw, fallback, fallback, true);
}
public static String normalizeThresholdChargeColor(String raw) {
if (raw == null) {
return "";
}
String value = raw.trim();
if (value.isEmpty()) {
return "";
}
if (THRESHOLD_CHARGE_USE_DEFAULT.equals(value)) {
return THRESHOLD_CHARGE_USE_DEFAULT;
}
return normalizeColor(value);
}
public static boolean isThresholdChargeDefault(String raw) {
return THRESHOLD_CHARGE_USE_DEFAULT.equals(raw);
}
public static String resolveCurrentColorHex(Context context, String fallbackRaw) {
int fallback = parseColorOrDefault(fallbackRaw, Color.WHITE);
return resolveCurrentColorHex(context, fallback, fallbackRaw);
}
public static String resolveCurrentColorHex(Context context, int referenceColor, String fallbackRaw) {
int fallback = parseColorOrDefault(fallbackRaw, Color.WHITE);
int resolved = resolveCurrentColor(context, referenceColor, fallback);
int alpha = Color.alpha(resolved);
if (alpha >= 255) {
return String.format(Locale.ROOT, "#%06X", resolved & 0xFFFFFF);
}
return String.format(Locale.ROOT, "#%08X", resolved);
}
public static ArrayList<Threshold> decodeThresholds(Collection<String> encoded) {
ArrayList<Threshold> result = new ArrayList<>();
if (encoded == null) {
return result;
}
for (String entry : encoded) {
if (entry == null) {
continue;
}
String[] parts = entry.split("\\|", -1);
if (parts.length < 2) {
continue;
}
int percent;
try {
percent = Integer.parseInt(parts[0].trim());
} catch (NumberFormatException ignored) {
continue;
}
String discharge = parts.length > 1 ? Uri.decode(parts[1]) : "";
String charge = parts.length > 2 ? Uri.decode(parts[2]) : "";
String normalizedDischarge = normalizeColor(discharge);
if (normalizedDischarge.isEmpty()) {
continue;
}
result.add(new Threshold(percent, normalizedDischarge, charge));
}
sortThresholds(result);
return result;
}
public static ArrayList<String> encodeThresholds(List<Threshold> thresholds) {
ArrayList<String> result = new ArrayList<>();
if (thresholds == null) {
return result;
}
for (Threshold threshold : thresholds) {
if (threshold == null) {
continue;
}
String discharge = normalizeColor(threshold.dischargeColor);
if (discharge.isEmpty()) {
continue;
}
String charge = normalizeThresholdChargeColor(threshold.chargeColor);
result.add(clampPercent(threshold.percent)
+ "|" + Uri.encode(discharge)
+ "|" + Uri.encode(charge));
}
Collections.sort(result);
return result;
}
public static void sortThresholds(List<Threshold> thresholds) {
if (thresholds == null) {
return;
}
Collections.sort(thresholds, Comparator.comparingInt(t -> t.percent));
}
public static int resolveColor(int batteryLevel,
boolean charging,
String defaultDischarge,
String defaultCharge,
List<Threshold> thresholds,
int referenceColor,
int fallback) {
String chosenDischarge = normalizeColor(defaultDischarge);
String chosenCharge = normalizeColor(defaultCharge);
if (thresholds != null && batteryLevel >= 0) {
for (Threshold threshold : thresholds) {
if (threshold == null) {
continue;
}
if (batteryLevel <= threshold.percent) {
chosenDischarge = normalizeColor(threshold.dischargeColor);
String thresholdCharge = normalizeThresholdChargeColor(threshold.chargeColor);
chosenCharge = isThresholdChargeDefault(thresholdCharge)
? normalizeColor(defaultCharge)
: thresholdCharge;
break;
}
}
}
if (charging) {
if (!chosenCharge.isEmpty()) {
return ColourExpressionSupport.resolveColor(chosenCharge, referenceColor, fallback, true);
}
if (!chosenDischarge.isEmpty()) {
return ColourExpressionSupport.resolveColor(chosenDischarge, referenceColor, fallback, true);
}
return fallback;
}
if (!chosenDischarge.isEmpty()) {
return ColourExpressionSupport.resolveColor(chosenDischarge, referenceColor, fallback, true);
}
return fallback;
}
public static int resolveCurrentColor(Context context, int fallback) {
return resolveCurrentColor(context, fallback, fallback);
}
public static int resolveCurrentColor(Context context, int referenceColor, int fallback) {
if (context == null) {
return fallback;
}
SbtSettings settings = SbtSettings.from(context);
BatterySnapshot snapshot = resolveBatterySnapshot(context);
return resolveColor(
snapshot.level,
snapshot.charging,
settings.batteryBarDefaultDischargeColor,
settings.batteryBarDefaultChargeColor,
settings.batteryBarThresholds,
referenceColor,
fallback);
}
private static BatterySnapshot resolveBatterySnapshot(Context context) {
IntentFilter filter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
Intent sticky = null;
try {
if (Build.VERSION.SDK_INT >= 33) {
sticky = context.registerReceiver(null, filter, Context.RECEIVER_EXPORTED);
} else {
sticky = context.registerReceiver(null, filter);
}
} catch (Throwable ignored) {
}
if (sticky != null) {
int level = sticky.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
int scale = sticky.getIntExtra(BatteryManager.EXTRA_SCALE, 100);
int percent = (level >= 0 && scale > 0)
? Math.round((level * 100f) / scale)
: -1;
int status = sticky.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
boolean charging = sticky.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
|| status == BatteryManager.BATTERY_STATUS_CHARGING
|| status == BatteryManager.BATTERY_STATUS_FULL;
return new BatterySnapshot(percent, charging);
}
BatteryManager batteryManager = context.getSystemService(BatteryManager.class);
int level = batteryManager != null
? batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
: -1;
boolean charging = batteryManager != null && batteryManager.isCharging();
return new BatterySnapshot(level, charging);
}
private static final class BatterySnapshot {
final int level;
final boolean charging;
BatterySnapshot(int level, boolean charging) {
this.level = level;
this.charging = charging;
}
}
public static int clampPercent(int percent) {
if (percent < 1) {
return 1;
}
if (percent > 100) {
return 100;
}
return percent;
}
}
@@ -0,0 +1,437 @@
package se.ajpanton.statusbartweak.shell.settings;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Rect;
import android.provider.Settings;
import android.text.TextUtils;
import android.view.Display;
import android.view.DisplayCutout;
import android.view.View;
import java.security.MessageDigest;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
public final class ClockCameraTypeSupport {
public static final String TYPE_BLOCKING = "blocking";
public static final String TYPE_UDC = "under_display";
private static final char ENTRY_SEPARATOR = '\u001f';
public static final class CameraInfo {
public int semDisplayDeviceType = Integer.MIN_VALUE;
public int fillUdcDisplayCutout = Integer.MIN_VALUE;
public boolean isUdcMainDisplay;
public String cutoutType;
public String cutoutString;
public Rect excludeRect;
public String cameraId;
public String automaticType = TYPE_BLOCKING;
public String effectiveType = TYPE_BLOCKING;
public boolean overridden;
}
private ClockCameraTypeSupport() {
}
public static String normalizeType(String value) {
if (TYPE_UDC.equals(value)) {
return TYPE_UDC;
}
return TYPE_BLOCKING;
}
public static String oppositeType(String value) {
return TYPE_UDC.equals(normalizeType(value)) ? TYPE_BLOCKING : TYPE_UDC;
}
public static String formatType(String value) {
return TYPE_UDC.equals(normalizeType(value))
? "under-display camera"
: "blocking cutout";
}
public static String buildCameraId(int semDisplayDeviceType, String cutoutType, String cutoutString) {
String descriptor = semDisplayDeviceType + "|"
+ safe(cutoutType) + "|"
+ safe(cutoutString);
return "cam_" + shortSha1(descriptor);
}
public static CameraInfo resolveCameraInfo(View root,
DisplayCutout displayCutout,
SbtSettings settings) {
CameraInfo info = new CameraInfo();
if (root == null) {
return info;
}
Context context = root.getContext();
if (context == null) {
finalizeCameraInfo(info, settings);
return info;
}
info.semDisplayDeviceType = readSemDisplayDeviceType(context);
info.fillUdcDisplayCutout = readGlobalInt(context, "fill_udc_display_cutout");
Object displayLifecycle = resolveDisplayLifecycle(context);
try {
ClassLoader classLoader = context.getClassLoader();
Class<?> inputClass = findClassIfExists(
"com.android.systemui.statusbar.phone.IndicatorGardenInputProperties",
classLoader);
Class<?> utilClass = findClassIfExists(
"com.android.systemui.statusbar.phone.IndicatorCutoutUtil",
classLoader);
if (inputClass == null || utilClass == null || displayLifecycle == null) {
finalizeCameraInfo(info, settings);
return info;
}
Object input = newInstance(inputClass, context);
Display display = root.getDisplay();
if (display != null) {
setIntField(input, "rotation", display.getRotation());
}
int width = root.getWidth();
if (width <= 0 && root.getRootView() != null) {
width = root.getRootView().getWidth();
}
if (width > 0) {
setIntField(input, "statusBarWidth", width);
}
Object util = newInstance(utilClass, context, input, displayLifecycle);
info.isUdcMainDisplay = asBoolean(callMethod(util, "isUDCMainDisplay"));
DisplayCutout presenterCutout = displayCutout;
if (info.isUdcMainDisplay && info.fillUdcDisplayCutout == 0) {
presenterCutout = null;
}
callMethod(input, "onGardenApplyWindowInsets", presenterCutout);
Object cutoutType = readObjectField(util, "cutoutType");
Object cutoutString = readObjectField(util, "cutoutString");
info.cutoutType = cutoutType != null ? String.valueOf(cutoutType) : null;
info.cutoutString = cutoutString != null ? String.valueOf(cutoutString) : null;
info.excludeRect = (Rect) callMethod(util, "getDisplayCutoutAreaToExclude");
} catch (Throwable ignored) {
}
finalizeCameraInfo(info, settings);
return info;
}
public static Map<String, String> parseOverrides(Set<String> encoded) {
if (encoded == null || encoded.isEmpty()) {
return Collections.emptyMap();
}
HashMap<String, String> out = new HashMap<>();
for (String item : encoded) {
if (TextUtils.isEmpty(item)) {
continue;
}
int split = item.indexOf(ENTRY_SEPARATOR);
if (split <= 0 || split >= item.length() - 1) {
continue;
}
String cameraId = item.substring(0, split);
String type = normalizeType(item.substring(split + 1));
if (!TextUtils.isEmpty(cameraId)) {
out.put(cameraId, type);
}
}
return out.isEmpty() ? Collections.emptyMap() : out;
}
public static Set<String> encodeOverrides(Map<String, String> overrides) {
if (overrides == null || overrides.isEmpty()) {
return Collections.emptySet();
}
HashSet<String> out = new HashSet<>();
for (Map.Entry<String, String> entry : overrides.entrySet()) {
String cameraId = entry.getKey();
if (TextUtils.isEmpty(cameraId)) {
continue;
}
out.add(cameraId + ENTRY_SEPARATOR + normalizeType(entry.getValue()));
}
return out.isEmpty() ? Collections.emptySet() : out;
}
public static void saveOverrides(SharedPreferences prefs, String key, Map<String, String> overrides) {
if (prefs == null || TextUtils.isEmpty(key)) {
return;
}
prefs.edit().putStringSet(key, encodeOverrides(overrides)).apply();
}
public static String findReportValue(String report, String key) {
if (TextUtils.isEmpty(report) || TextUtils.isEmpty(key)) {
return "";
}
String prefix = key + "=";
String[] lines = report.split("\\r?\\n");
for (String line : lines) {
if (line != null && line.startsWith(prefix)) {
return line.substring(prefix.length()).trim();
}
}
return "";
}
private static void finalizeCameraInfo(CameraInfo info, SbtSettings settings) {
if (info == null) {
return;
}
info.cameraId = buildCameraId(
info.semDisplayDeviceType,
info.cutoutType,
info.cutoutString);
boolean explicitUdc = info.isUdcMainDisplay || "UDC".equals(info.cutoutType);
info.automaticType = explicitUdc ? TYPE_UDC : TYPE_BLOCKING;
String override = settings != null && settings.clockCameraTypeOverrides != null
? settings.clockCameraTypeOverrides.get(info.cameraId)
: null;
info.overridden = !TextUtils.isEmpty(override);
info.effectiveType = info.overridden
? normalizeType(override)
: info.automaticType;
}
private static Object resolveDisplayLifecycle(Context context) {
if (context == null) {
return null;
}
try {
ClassLoader classLoader = context.getClassLoader();
Class<?> dependencyClass = findClassIfExists(
"com.android.systemui.Dependency",
classLoader);
Class<?> displayLifecycleClass = findClassIfExists(
"com.android.systemui.keyguard.DisplayLifecycle",
classLoader);
if (dependencyClass == null || displayLifecycleClass == null) {
return null;
}
Object dependency = getStaticObjectField(dependencyClass, "sDependency");
if (dependency == null) {
return null;
}
return callMethod(dependency, "getDependencyInner", displayLifecycleClass);
} catch (Throwable ignored) {
return null;
}
}
private static int readSemDisplayDeviceType(Context context) {
if (context == null) {
return Integer.MIN_VALUE;
}
try {
return getIntField(
context.getResources().getConfiguration(),
"semDisplayDeviceType");
} catch (Throwable ignored) {
return Integer.MIN_VALUE;
}
}
private static int readGlobalInt(Context context, String name) {
if (context == null || TextUtils.isEmpty(name)) {
return Integer.MIN_VALUE;
}
try {
return Settings.Global.getInt(context.getContentResolver(), name, 0);
} catch (Throwable ignored) {
return Integer.MIN_VALUE;
}
}
private static Object readObjectField(Object target, String fieldName) {
if (target == null || TextUtils.isEmpty(fieldName)) {
return null;
}
try {
return getObjectField(target, fieldName);
} catch (Throwable ignored) {
return null;
}
}
private static Class<?> findClassIfExists(String className, ClassLoader classLoader) {
try {
return Class.forName(className, false, classLoader);
} catch (Throwable ignored) {
return null;
}
}
private static Object newInstance(Class<?> cls, Object... args) throws Exception {
Constructor<?>[] constructors = cls.getConstructors();
for (Constructor<?> constructor : constructors) {
Class<?>[] parameterTypes = constructor.getParameterTypes();
if (parameterTypes.length != args.length) {
continue;
}
if (!areArgumentsCompatible(parameterTypes, args)) {
continue;
}
return constructor.newInstance(args);
}
throw new NoSuchMethodException("No matching constructor for " + cls.getName());
}
private static Object callMethod(Object target, String methodName, Object... args) throws Exception {
Method method = findCompatibleMethod(target.getClass(), methodName, args);
if (method == null) {
throw new NoSuchMethodException(methodName);
}
method.setAccessible(true);
return method.invoke(target, args);
}
private static Method findCompatibleMethod(Class<?> cls, String methodName, Object... args) {
for (Method method : cls.getMethods()) {
if (!method.getName().equals(methodName)) {
continue;
}
if (areArgumentsCompatible(method.getParameterTypes(), args)) {
return method;
}
}
for (Method method : cls.getDeclaredMethods()) {
if (!method.getName().equals(methodName)) {
continue;
}
if (areArgumentsCompatible(method.getParameterTypes(), args)) {
return method;
}
}
return null;
}
private static boolean areArgumentsCompatible(Class<?>[] parameterTypes, Object[] args) {
if (parameterTypes.length != args.length) {
return false;
}
for (int i = 0; i < parameterTypes.length; i++) {
Object arg = args[i];
if (arg == null) {
if (parameterTypes[i].isPrimitive()) {
return false;
}
continue;
}
Class<?> boxed = boxType(parameterTypes[i]);
if (!boxed.isInstance(arg)) {
return false;
}
}
return true;
}
private static void setIntField(Object target, String fieldName, int value) throws Exception {
Field field = findField(target.getClass(), fieldName);
if (field == null) {
throw new NoSuchFieldException(fieldName);
}
field.setAccessible(true);
field.setInt(target, value);
}
private static int getIntField(Object target, String fieldName) throws Exception {
Field field = findField(target.getClass(), fieldName);
if (field == null) {
throw new NoSuchFieldException(fieldName);
}
field.setAccessible(true);
return field.getInt(target);
}
private static Object getObjectField(Object target, String fieldName) throws Exception {
Field field = findField(target.getClass(), fieldName);
if (field == null) {
throw new NoSuchFieldException(fieldName);
}
field.setAccessible(true);
return field.get(target);
}
private static Object getStaticObjectField(Class<?> cls, String fieldName) throws Exception {
Field field = findField(cls, fieldName);
if (field == null) {
throw new NoSuchFieldException(fieldName);
}
field.setAccessible(true);
return field.get(null);
}
private static Field findField(Class<?> cls, String fieldName) {
Class<?> current = cls;
while (current != null) {
try {
return current.getDeclaredField(fieldName);
} catch (NoSuchFieldException ignored) {
current = current.getSuperclass();
}
}
return null;
}
private static Class<?> boxType(Class<?> cls) {
if (!cls.isPrimitive()) {
return cls;
}
if (cls == int.class) {
return Integer.class;
}
if (cls == boolean.class) {
return Boolean.class;
}
if (cls == long.class) {
return Long.class;
}
if (cls == float.class) {
return Float.class;
}
if (cls == double.class) {
return Double.class;
}
if (cls == short.class) {
return Short.class;
}
if (cls == byte.class) {
return Byte.class;
}
if (cls == char.class) {
return Character.class;
}
return cls;
}
private static boolean asBoolean(Object value) {
return value instanceof Boolean && (Boolean) value;
}
private static String safe(String value) {
return value != null ? value : "";
}
private static String shortSha1(String source) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
byte[] hash = digest.digest(source.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder out = new StringBuilder();
for (int i = 0; i < Math.min(hash.length, 5); i++) {
out.append(String.format(Locale.US, "%02x", hash[i] & 0xff));
}
return out.toString();
} catch (Throwable ignored) {
return Integer.toHexString(source.hashCode());
}
}
}
@@ -0,0 +1,394 @@
package se.ajpanton.statusbartweak.shell.settings;
import android.graphics.Color;
import android.text.TextUtils;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class ColourExpressionSupport {
private static final Pattern OPERATION_PATTERN =
Pattern.compile("([+\\-*/])\\s*(" + hexOperandPattern() + "|\\d+(?:\\.\\d+)?|\\.\\d+)");
private static final Pattern LEADING_HEX_PATTERN =
Pattern.compile("(?i)^#?(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{4}|[0-9a-f]{3})");
private ColourExpressionSupport() {
}
public static String normaliseExpression(String raw, boolean assumeHashPrefix) {
if (raw == null) {
return "";
}
String value = raw.trim();
if (value.isEmpty()) {
return "";
}
int variantSplit = findVariantSplit(value);
String left = value;
String right = null;
if (variantSplit >= 0) {
left = value.substring(0, variantSplit).trim();
right = value.substring(variantSplit + 1).trim();
}
if (!isValidSegment(left, assumeHashPrefix, false, Color.WHITE)) {
return "";
}
if (right != null && !isValidSegment(right, assumeHashPrefix, true, Color.WHITE)) {
return "";
}
if (right == null) {
return left;
}
return left + "|" + right;
}
public static int resolveColor(String raw,
int referenceColor,
int fallbackColor,
boolean assumeHashPrefix) {
String value = normaliseExpression(raw, assumeHashPrefix);
if (value.isEmpty()) {
return fallbackColor;
}
int variantSplit = findVariantSplit(value);
if (variantSplit < 0) {
return resolveSegment(value, fallbackColor, assumeHashPrefix, false);
}
String left = value.substring(0, variantSplit).trim();
String right = value.substring(variantSplit + 1).trim();
int brightVariant = resolveSegment(left, fallbackColor, assumeHashPrefix, false);
if (!isDark(referenceColor)) {
return brightVariant;
}
return resolveSegment(right, brightVariant, assumeHashPrefix, true);
}
public static String resolveColorHex(String raw,
int referenceColor,
int fallbackColor,
boolean assumeHashPrefix) {
int resolved = resolveColor(raw, referenceColor, fallbackColor, assumeHashPrefix);
int alpha = Color.alpha(resolved);
if (alpha >= 255) {
return String.format(Locale.ROOT, "#%06X", resolved & 0xFFFFFF);
}
return String.format(Locale.ROOT, "#%08X", resolved);
}
private static boolean isValidSegment(String segment,
boolean assumeHashPrefix,
boolean allowInheritedBase,
int baseColor) {
if (TextUtils.isEmpty(segment)) {
return false;
}
try {
resolveSegment(segment, baseColor, assumeHashPrefix, allowInheritedBase);
return true;
} catch (IllegalArgumentException ignored) {
return false;
}
}
private static int resolveSegment(String segment,
int baseColor,
boolean assumeHashPrefix,
boolean allowInheritedBase) {
String value = segment != null ? segment.trim() : "";
if (value.isEmpty()) {
throw new IllegalArgumentException("Empty colour segment");
}
int index = 0;
boolean invert = false;
if (value.regionMatches(true, 0, "inv", 0, 3)) {
invert = true;
index = 3;
}
double[] channels;
double alpha;
String remainder;
if (startsWithOperator(value, index)) {
if (!allowInheritedBase) {
throw new IllegalArgumentException("Missing starting colour");
}
channels = channelsFor(baseColor);
alpha = Color.alpha(baseColor);
remainder = value.substring(index).trim();
} else {
LeadingColour leading = parseLeadingColour(value, index, assumeHashPrefix);
channels = channelsFor(leading.color);
alpha = Color.alpha(leading.color);
remainder = value.substring(leading.endIndex).trim();
}
if (invert) {
invertChannels(channels);
}
alpha = applyOperations(channels, alpha, remainder);
return colorFromChannels(channels, alpha);
}
private static LeadingColour parseLeadingColour(String value,
int start,
boolean assumeHashPrefix) {
String remainder = value.substring(start).trim();
Matcher matcher = LEADING_HEX_PATTERN.matcher(remainder);
if (!matcher.find()) {
throw new IllegalArgumentException("Missing starting colour");
}
String token = matcher.group();
if (assumeHashPrefix && token.charAt(0) != '#') {
token = "#" + token;
}
int color = parseHexColor(token, assumeHashPrefix);
return new LeadingColour(color, start + matcher.end());
}
private static double applyOperations(double[] channels, double alpha, String remainder) {
int index = 0;
while (index < remainder.length()) {
while (index < remainder.length() && Character.isWhitespace(remainder.charAt(index))) {
index++;
}
if (index >= remainder.length()) {
break;
}
Matcher matcher = OPERATION_PATTERN.matcher(remainder);
matcher.region(index, remainder.length());
if (!matcher.lookingAt()) {
throw new IllegalArgumentException("Invalid colour operation");
}
char operator = matcher.group(1).charAt(0);
String operandText = matcher.group(2);
alpha = applyOperation(channels, alpha, operator, operandText);
index = matcher.end();
}
return alpha;
}
private static double applyOperation(double[] channels, double alpha, char operator, String operandText) {
Operand operand = parseOperand(operandText);
for (int i = 0; i < 3; i++) {
double value = channels[i];
double rhs = operand.rgb[i];
if (operand.isHex && (operator == '*' || operator == '/')) {
rhs = rhs / 255d;
}
switch (operator) {
case '+':
channels[i] = value + rhs;
break;
case '-':
channels[i] = value - rhs;
break;
case '*':
channels[i] = value * rhs;
break;
case '/':
channels[i] = rhs == 0d ? value : value / rhs;
break;
default:
throw new IllegalArgumentException("Unknown operator");
}
}
if (operand.hasAlpha) {
double rhs = operand.alpha;
if (operand.isHex && (operator == '*' || operator == '/')) {
rhs = rhs / 255d;
}
switch (operator) {
case '+':
alpha = alpha + rhs;
break;
case '-':
alpha = alpha - rhs;
break;
case '*':
alpha = alpha * rhs;
break;
case '/':
alpha = rhs == 0d ? alpha : alpha / rhs;
break;
default:
throw new IllegalArgumentException("Unknown operator");
}
}
return alpha;
}
private static Operand parseOperand(String operandText) {
String value = operandText != null ? operandText.trim() : "";
if (value.isEmpty()) {
throw new IllegalArgumentException("Missing operand");
}
if (value.charAt(0) == '#') {
return parseHexOperand(value);
}
double scalar;
try {
scalar = Double.parseDouble(value);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid operand", e);
}
return new Operand(new double[] { scalar, scalar, scalar }, 0d, false, false);
}
private static int parseHexColor(String raw, boolean assumeHashPrefix) {
String value = raw != null ? raw.trim() : "";
if (value.isEmpty()) {
throw new IllegalArgumentException("Empty colour");
}
if (assumeHashPrefix && value.charAt(0) != '#') {
value = "#" + value;
}
if (value.charAt(0) != '#') {
throw new IllegalArgumentException("Expected colour literal");
}
String hex = value.substring(1);
if (hex.length() == 3) {
hex = expandShortHex(hex);
} else if (hex.length() == 4) {
hex = expandShortHex(hex);
} else if (hex.length() != 6 && hex.length() != 8) {
throw new IllegalArgumentException("Unsupported colour length");
}
try {
return Color.parseColor("#" + hex);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid colour", e);
}
}
private static String expandShortHex(String hex) {
StringBuilder out = new StringBuilder(hex.length() * 2);
for (int i = 0; i < hex.length(); i++) {
char c = hex.charAt(i);
out.append(c).append(c);
}
return out.toString();
}
private static double[] channelsFor(int color) {
return new double[] {
Color.red(color),
Color.green(color),
Color.blue(color)
};
}
private static void invertChannels(double[] channels) {
for (int i = 0; i < 3; i++) {
channels[i] = 255d - channels[i];
}
}
private static int colorFromChannels(double[] channels, double alpha) {
int red = clampChannel(channels[0]);
int green = clampChannel(channels[1]);
int blue = clampChannel(channels[2]);
return Color.argb(clampChannel(alpha), red, green, blue);
}
private static Operand parseHexOperand(String raw) {
String value = raw != null ? raw.trim() : "";
if (value.isEmpty() || value.charAt(0) != '#') {
throw new IllegalArgumentException("Expected colour literal");
}
String hex = value.substring(1);
if (hex.length() == 3 || hex.length() == 4) {
hex = expandShortHex(hex);
} else if (hex.length() != 6 && hex.length() != 8) {
throw new IllegalArgumentException("Unsupported colour length");
}
boolean hasAlpha = hex.length() == 8;
int offset = hasAlpha ? 2 : 0;
double alpha = 0d;
if (hasAlpha) {
alpha = parseHexByte(hex.substring(0, 2));
}
double[] rgb = new double[] {
parseHexByte(hex.substring(offset, offset + 2)),
parseHexByte(hex.substring(offset + 2, offset + 4)),
parseHexByte(hex.substring(offset + 4, offset + 6))
};
return new Operand(rgb, alpha, hasAlpha, true);
}
private static double parseHexByte(String value) {
return Integer.parseInt(value, 16);
}
private static int clampChannel(double value) {
if (Double.isNaN(value) || Double.isInfinite(value)) {
return 0;
}
if (value <= 0d) {
return 0;
}
if (value >= 255d) {
return 255;
}
return (int) Math.round(value);
}
private static boolean startsWithOperator(String value, int start) {
if (start < 0 || start >= value.length()) {
return false;
}
char c = value.charAt(start);
return c == '+' || c == '-' || c == '*' || c == '/';
}
private static int findVariantSplit(String value) {
if (TextUtils.isEmpty(value)) {
return -1;
}
boolean quoted = false;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '\'') {
quoted = !quoted;
continue;
}
if (!quoted && c == '|') {
return i;
}
}
return -1;
}
private static boolean isDark(int color) {
double brightness = (Color.red(color) * 299d
+ Color.green(color) * 587d
+ Color.blue(color) * 114d) / 1000d;
return brightness < 128d;
}
private static String hexOperandPattern() {
return "#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})";
}
private static final class LeadingColour {
final int color;
final int endIndex;
LeadingColour(int color, int endIndex) {
this.color = color;
this.endIndex = endIndex;
}
}
private static final class Operand {
final double[] rgb;
final double alpha;
final boolean hasAlpha;
final boolean isHex;
Operand(double[] rgb, double alpha, boolean hasAlpha, boolean isHex) {
this.rgb = rgb;
this.alpha = alpha;
this.hasAlpha = hasAlpha;
this.isHex = isHex;
}
}
}
@@ -0,0 +1,132 @@
package se.ajpanton.statusbartweak.shell.settings;
public final class SbtDefaults {
public static final int LOCKSCREEN_DOT_Y_OFFSET_DP = 3;
public static final boolean ICONS_AOD_CUTOUT_LIMIT_DEFAULT = true;
public static final boolean ICONS_LOCK_CUTOUT_LIMIT_DEFAULT = true;
// Unlocked slider defaults and bounds.
public static final int UNLOCKED_MAX_ICONS_PER_ROW_DEFAULT = 3;
public static final int UNLOCKED_MAX_ICONS_PER_ROW_MIN = 3;
public static final int UNLOCKED_MAX_ICONS_PER_ROW_MAX = 50;
// Icons mode (AOD).
public static final int ICONS_AOD_MAX_ICONS_PER_ROW_DEFAULT = 8;
public static final int ICONS_AOD_MAX_ICONS_PER_ROW_MIN = 1;
public static final int ICONS_AOD_MAX_ICONS_PER_ROW_MAX = 50;
public static final int ICONS_AOD_MAX_ROWS_DEFAULT = 1;
public static final int ICONS_AOD_MAX_ROWS_MIN = 1;
public static final int ICONS_AOD_MAX_ROWS_MAX = 5;
public static final boolean ICONS_AOD_EVEN_DISTRIBUTION_DEFAULT = false;
// Icons mode (Lockscreen).
public static final int ICONS_LOCK_MAX_ICONS_PER_ROW_DEFAULT = 8;
public static final int ICONS_LOCK_MAX_ICONS_PER_ROW_MIN = 1;
public static final int ICONS_LOCK_MAX_ICONS_PER_ROW_MAX = 50;
public static final int ICONS_LOCK_MAX_ROWS_DEFAULT = 1;
public static final int ICONS_LOCK_MAX_ROWS_MIN = 1;
public static final int ICONS_LOCK_MAX_ROWS_MAX = 5;
public static final boolean ICONS_LOCK_EVEN_DISTRIBUTION_DEFAULT = false;
// Cards mode (AOD).
public static final int CARDS_AOD_MAX_ICONS_PER_ROW_DEFAULT = 8;
public static final int CARDS_AOD_MAX_ICONS_PER_ROW_MIN = 1;
public static final int CARDS_AOD_MAX_ICONS_PER_ROW_MAX = 15;
public static final int CARDS_AOD_MAX_ROWS_DEFAULT = 3;
public static final int CARDS_AOD_MAX_ROWS_MIN = 1;
public static final int CARDS_AOD_MAX_ROWS_MAX = 5;
public static final boolean CARDS_AOD_EVEN_DISTRIBUTION_DEFAULT = true;
public static final boolean CARDS_AOD_LIMIT_TO_WIDTH_DEFAULT = false;
// Cards mode (Lockscreen).
public static final int CARDS_LOCK_MAX_ICONS_PER_ROW_DEFAULT = 8;
public static final int CARDS_LOCK_MAX_ICONS_PER_ROW_MIN = 1;
public static final int CARDS_LOCK_MAX_ICONS_PER_ROW_MAX = 15;
public static final int CARDS_LOCK_MAX_ROWS_DEFAULT = 3;
public static final int CARDS_LOCK_MAX_ROWS_MIN = 1;
public static final int CARDS_LOCK_MAX_ROWS_MAX = 5;
public static final boolean CARDS_LOCK_EVEN_DISTRIBUTION_DEFAULT = true;
public static final boolean CARDS_LOCK_LIMIT_TO_WIDTH_DEFAULT = false;
public static final boolean AOD_KEEP_VISIBLE_DURING_CALL_DEFAULT = false;
public static final boolean BATTERY_LOCKSCREEN_UNPLUGGED_PERCENT_DEFAULT = true;
public static final boolean BATTERY_AOD_UNPLUGGED_PERCENT_DEFAULT = true;
public static final boolean BATTERY_BAR_ENABLED_DEFAULT = false;
public static final String BATTERY_BAR_POSITION_DEFAULT = "top";
public static final String BATTERY_BAR_ALIGNMENT_DEFAULT = "ltr";
public static final int BATTERY_BAR_THICKNESS_DP_DEFAULT = 2;
public static final int BATTERY_BAR_THICKNESS_DP_MIN = 1;
public static final int BATTERY_BAR_THICKNESS_DP_MAX = 12;
public static final int BATTERY_BAR_EDGE_OFFSET_DP_DEFAULT = 0;
public static final int BATTERY_BAR_EDGE_OFFSET_DP_MIN = 0;
public static final int BATTERY_BAR_EDGE_OFFSET_DP_MAX = 64;
public static final int BATTERY_BAR_MIN_LEVEL_DEFAULT = 0;
public static final int BATTERY_BAR_MIN_LEVEL_MIN = 0;
public static final int BATTERY_BAR_MIN_LEVEL_MAX = 99;
public static final int BATTERY_BAR_MAX_LEVEL_DEFAULT = 100;
public static final int BATTERY_BAR_MAX_LEVEL_MIN = 1;
public static final int BATTERY_BAR_MAX_LEVEL_MAX = 100;
public static final String BATTERY_BAR_DEFAULT_DISCHARGE_COLOR_DEFAULT = "";
public static final String BATTERY_BAR_DEFAULT_CHARGE_COLOR_DEFAULT = "";
public static final boolean CLOCK_ENABLED_DEFAULT = false;
public static final boolean CLOCK_ENABLED_LOCKSCREEN_DEFAULT = false;
public static final boolean CLOCK_ENABLED_UNLOCKED_DEFAULT = false;
public static final String CLOCK_POSITION_DEFAULT = "middle";
public static final int CLOCK_HORIZONTAL_OFFSET_PX_DEFAULT = 0;
public static final String CLOCK_MIDDLE_CUTOUT_SIDE_DEFAULT = "left";
public static final boolean CLOCK_MIDDLE_AUTO_NEAREST_SIDE_DEFAULT = true;
public static final int CLOCK_MIDDLE_CUTOUT_GAP_PX_DEFAULT = 0;
public static final int CLOCK_MIDDLE_CUTOUT_GAP_PX_MIN = -5;
public static final int CLOCK_MIDDLE_CUTOUT_GAP_PX_MAX = 20;
public static final int CLOCK_VERTICAL_OFFSET_PX_DEFAULT = 0;
public static final int CLOCK_VERTICAL_OFFSET_PX_MIN = -99;
public static final int CLOCK_VERTICAL_OFFSET_PX_MAX = 99;
public static final boolean CLOCK_SHOW_SECONDS_DEFAULT = false;
public static final boolean CLOCK_SHOW_DATE_PREFIX_DEFAULT = false;
public static final boolean CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT = false;
public static final String CLOCK_CUSTOM_FORMAT_DEFAULT = "";
public static final boolean CLOCK_IGNORE_UNDER_DISPLAY_CAMERAS_DEFAULT = true;
public static final boolean DEBUG_WRITE_LOGS_DEFAULT = true;
public static final boolean DEBUG_VISUALIZE_NOTIFICATION_CONTAINER_DEFAULT = false;
public static final boolean DEBUG_VISUALIZE_STATUS_CONTAINER_DEFAULT = false;
public static final boolean DEBUG_VISUALIZE_CLOCK_CONTAINER_DEFAULT = false;
public static final boolean DEBUG_VISUALIZE_CHIP_CONTAINER_DEFAULT = false;
public static final boolean DEBUG_VISUALIZE_CAMERA_CUTOUT_DEFAULT = false;
public static final int LAYOUT_PADDING_CUTOUT_PX_DEFAULT = 0;
public static final int LAYOUT_PADDING_LEFT_PX_DEFAULT = 0;
public static final int LAYOUT_PADDING_RIGHT_PX_DEFAULT = 0;
public static final int LAYOUT_ITEM_PADDING_PX_DEFAULT = 11;
public static final int LAYOUT_ITEM_PADDING_PX_MIN = 0;
public static final int LAYOUT_ITEM_PADDING_PX_MAX = 999;
public static final boolean LAYOUT_PADDING_LEFT_ADD_STOCK_DEFAULT = true;
public static final boolean LAYOUT_PADDING_RIGHT_ADD_STOCK_DEFAULT = true;
public static final boolean LAYOUT_IGNORE_UNDER_DISPLAY_CAMERAS_DEFAULT = true;
public static final String LAYOUT_ITEM_ORDER_DEFAULT = "clock,chip,status,notifications";
public static final String LAYOUT_CHIP_POSITION_DEFAULT = "left";
public static final boolean LAYOUT_CHIP_ENABLED_LOCKSCREEN_DEFAULT = true;
public static final boolean LAYOUT_CHIP_ENABLED_UNLOCKED_DEFAULT = true;
public static final String LAYOUT_CHIP_MIDDLE_SIDE_DEFAULT = "left";
public static final boolean LAYOUT_CHIP_MIDDLE_AUTO_NEAREST_SIDE_DEFAULT = true;
public static final int LAYOUT_CHIP_VERTICAL_OFFSET_PX_DEFAULT = 0;
public static final String LAYOUT_NOTIF_POSITION_DEFAULT = "left";
public static final String LAYOUT_NOTIF_SORT_ORDER_DEFAULT = "automatic";
public static final boolean LAYOUT_NOTIF_SHOW_DOT_IF_TRUNCATED_DEFAULT = true;
public static final boolean LAYOUT_NOTIF_ENABLED_AOD_DEFAULT = true;
public static final boolean LAYOUT_NOTIF_ENABLED_LOCKSCREEN_DEFAULT = true;
public static final boolean LAYOUT_NOTIF_ENABLED_UNLOCKED_DEFAULT = true;
public static final String LAYOUT_NOTIF_MIDDLE_SIDE_DEFAULT = "left";
public static final boolean LAYOUT_NOTIF_MIDDLE_AUTO_NEAREST_SIDE_DEFAULT = true;
public static final int LAYOUT_NOTIF_VERTICAL_OFFSET_PX_DEFAULT = 0;
public static final String LAYOUT_STATUS_POSITION_DEFAULT = "right";
public static final boolean LAYOUT_STATUS_ENABLED_AOD_DEFAULT = true;
public static final boolean LAYOUT_STATUS_ENABLED_LOCKSCREEN_DEFAULT = true;
public static final boolean LAYOUT_STATUS_ENABLED_UNLOCKED_DEFAULT = true;
public static final boolean LAYOUT_STATUS_SHOW_DOT_IF_TRUNCATED_DEFAULT = true;
public static final String LAYOUT_STATUS_MIDDLE_SIDE_DEFAULT = "left";
public static final boolean LAYOUT_STATUS_MIDDLE_AUTO_NEAREST_SIDE_DEFAULT = true;
public static final int LAYOUT_STATUS_VERTICAL_OFFSET_PX_DEFAULT = 0;
private SbtDefaults() {
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,334 @@
package se.ajpanton.statusbartweak.shell.settings;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import java.util.ArrayList;
import java.util.Map;
public class SbtSettingsProvider extends ContentProvider {
public static final String AUTHORITY = "se.ajpanton.statusbartweak.settings";
public static final String METHOD_GET_UNLOCKED = "get_unlocked";
public static final String METHOD_GET_ALL = "get_all";
public static final String METHOD_SET_CLOCK_CAMERA_DEBUG_STATE = "set_clock_camera_debug_state";
public static final String METHOD_NOTE_SEEN_ICON_APP = "note_seen_icon_app";
public static final String EXTRA_VALUE = "value";
private static final Object LOCK = new Object();
@Override
public boolean onCreate() {
return true;
}
@Override
public Bundle call(String method, String arg, Bundle extras) {
Context ctx = getContext();
if (ctx == null) {
return null;
}
SbtSettings.restoreCredentialProtectedPreferences(ctx);
SharedPreferences prefs = ctx.getSharedPreferences(SbtSettings.PREFS_NAME, Context.MODE_PRIVATE);
if (METHOD_GET_UNLOCKED.equals(method)) {
int value = prefs.getInt(SbtSettings.KEY_UNLOCKED_MAX_ICONS_PER_ROW,
SbtDefaults.UNLOCKED_MAX_ICONS_PER_ROW_DEFAULT);
Bundle out = new Bundle();
out.putInt(EXTRA_VALUE, value);
return out;
}
if (METHOD_GET_ALL.equals(method)) {
Bundle out = new Bundle();
out.putBoolean(SbtSettings.KEY_ICONS_AOD_CUTOUT_LIMIT,
prefs.getBoolean(SbtSettings.KEY_ICONS_AOD_CUTOUT_LIMIT, SbtDefaults.ICONS_AOD_CUTOUT_LIMIT_DEFAULT));
out.putBoolean(SbtSettings.KEY_ICONS_LOCK_CUTOUT_LIMIT,
prefs.getBoolean(SbtSettings.KEY_ICONS_LOCK_CUTOUT_LIMIT, SbtDefaults.ICONS_LOCK_CUTOUT_LIMIT_DEFAULT));
out.putInt(SbtSettings.KEY_UNLOCKED_MAX_ICONS_PER_ROW,
prefs.getInt(SbtSettings.KEY_UNLOCKED_MAX_ICONS_PER_ROW, SbtDefaults.UNLOCKED_MAX_ICONS_PER_ROW_DEFAULT));
out.putInt(SbtSettings.KEY_ICONS_AOD_MAX_ICONS_PER_ROW,
prefs.getInt(SbtSettings.KEY_ICONS_AOD_MAX_ICONS_PER_ROW, SbtDefaults.ICONS_AOD_MAX_ICONS_PER_ROW_DEFAULT));
out.putInt(SbtSettings.KEY_ICONS_AOD_MAX_ROWS,
prefs.getInt(SbtSettings.KEY_ICONS_AOD_MAX_ROWS, SbtDefaults.ICONS_AOD_MAX_ROWS_DEFAULT));
out.putBoolean(SbtSettings.KEY_ICONS_AOD_EVEN_DISTRIBUTION,
prefs.getBoolean(SbtSettings.KEY_ICONS_AOD_EVEN_DISTRIBUTION, SbtDefaults.ICONS_AOD_EVEN_DISTRIBUTION_DEFAULT));
out.putInt(SbtSettings.KEY_ICONS_LOCK_MAX_ICONS_PER_ROW,
prefs.getInt(SbtSettings.KEY_ICONS_LOCK_MAX_ICONS_PER_ROW, SbtDefaults.ICONS_LOCK_MAX_ICONS_PER_ROW_DEFAULT));
out.putInt(SbtSettings.KEY_ICONS_LOCK_MAX_ROWS,
prefs.getInt(SbtSettings.KEY_ICONS_LOCK_MAX_ROWS, SbtDefaults.ICONS_LOCK_MAX_ROWS_DEFAULT));
out.putBoolean(SbtSettings.KEY_ICONS_LOCK_EVEN_DISTRIBUTION,
prefs.getBoolean(SbtSettings.KEY_ICONS_LOCK_EVEN_DISTRIBUTION, SbtDefaults.ICONS_LOCK_EVEN_DISTRIBUTION_DEFAULT));
out.putInt(SbtSettings.KEY_CARDS_AOD_MAX_ICONS_PER_ROW,
prefs.getInt(SbtSettings.KEY_CARDS_AOD_MAX_ICONS_PER_ROW, SbtDefaults.CARDS_AOD_MAX_ICONS_PER_ROW_DEFAULT));
out.putInt(SbtSettings.KEY_CARDS_AOD_MAX_ROWS,
prefs.getInt(SbtSettings.KEY_CARDS_AOD_MAX_ROWS, SbtDefaults.CARDS_AOD_MAX_ROWS_DEFAULT));
out.putBoolean(SbtSettings.KEY_CARDS_AOD_EVEN_DISTRIBUTION,
prefs.getBoolean(SbtSettings.KEY_CARDS_AOD_EVEN_DISTRIBUTION, SbtDefaults.CARDS_AOD_EVEN_DISTRIBUTION_DEFAULT));
out.putBoolean(SbtSettings.KEY_CARDS_AOD_LIMIT_TO_WIDTH,
prefs.getBoolean(SbtSettings.KEY_CARDS_AOD_LIMIT_TO_WIDTH, SbtDefaults.CARDS_AOD_LIMIT_TO_WIDTH_DEFAULT));
out.putInt(SbtSettings.KEY_CARDS_LOCK_MAX_ICONS_PER_ROW,
prefs.getInt(SbtSettings.KEY_CARDS_LOCK_MAX_ICONS_PER_ROW, SbtDefaults.CARDS_LOCK_MAX_ICONS_PER_ROW_DEFAULT));
out.putInt(SbtSettings.KEY_CARDS_LOCK_MAX_ROWS,
prefs.getInt(SbtSettings.KEY_CARDS_LOCK_MAX_ROWS, SbtDefaults.CARDS_LOCK_MAX_ROWS_DEFAULT));
out.putBoolean(SbtSettings.KEY_CARDS_LOCK_EVEN_DISTRIBUTION,
prefs.getBoolean(SbtSettings.KEY_CARDS_LOCK_EVEN_DISTRIBUTION, SbtDefaults.CARDS_LOCK_EVEN_DISTRIBUTION_DEFAULT));
out.putBoolean(SbtSettings.KEY_CARDS_LOCK_LIMIT_TO_WIDTH,
prefs.getBoolean(SbtSettings.KEY_CARDS_LOCK_LIMIT_TO_WIDTH, SbtDefaults.CARDS_LOCK_LIMIT_TO_WIDTH_DEFAULT));
out.putBoolean(SbtSettings.KEY_AOD_KEEP_VISIBLE_DURING_CALL,
prefs.getBoolean(SbtSettings.KEY_AOD_KEEP_VISIBLE_DURING_CALL, SbtDefaults.AOD_KEEP_VISIBLE_DURING_CALL_DEFAULT));
out.putBoolean(SbtSettings.KEY_BATTERY_LOCKSCREEN_UNPLUGGED_PERCENT,
prefs.getBoolean(SbtSettings.KEY_BATTERY_LOCKSCREEN_UNPLUGGED_PERCENT, SbtDefaults.BATTERY_LOCKSCREEN_UNPLUGGED_PERCENT_DEFAULT));
out.putBoolean(SbtSettings.KEY_BATTERY_AOD_UNPLUGGED_PERCENT,
prefs.getBoolean(SbtSettings.KEY_BATTERY_AOD_UNPLUGGED_PERCENT, SbtDefaults.BATTERY_AOD_UNPLUGGED_PERCENT_DEFAULT));
out.putBoolean(SbtSettings.KEY_BATTERY_BAR_ENABLED,
prefs.getBoolean(SbtSettings.KEY_BATTERY_BAR_ENABLED, SbtDefaults.BATTERY_BAR_ENABLED_DEFAULT));
out.putString(SbtSettings.KEY_BATTERY_BAR_POSITION,
prefs.getString(SbtSettings.KEY_BATTERY_BAR_POSITION, SbtDefaults.BATTERY_BAR_POSITION_DEFAULT));
out.putString(SbtSettings.KEY_BATTERY_BAR_ALIGNMENT,
prefs.getString(SbtSettings.KEY_BATTERY_BAR_ALIGNMENT, SbtDefaults.BATTERY_BAR_ALIGNMENT_DEFAULT));
out.putInt(SbtSettings.KEY_BATTERY_BAR_THICKNESS_DP,
prefs.getInt(SbtSettings.KEY_BATTERY_BAR_THICKNESS_DP, SbtDefaults.BATTERY_BAR_THICKNESS_DP_DEFAULT));
out.putInt(SbtSettings.KEY_BATTERY_BAR_EDGE_OFFSET_DP,
prefs.getInt(SbtSettings.KEY_BATTERY_BAR_EDGE_OFFSET_DP, SbtDefaults.BATTERY_BAR_EDGE_OFFSET_DP_DEFAULT));
out.putInt(SbtSettings.KEY_BATTERY_BAR_MIN_LEVEL,
prefs.getInt(SbtSettings.KEY_BATTERY_BAR_MIN_LEVEL, SbtDefaults.BATTERY_BAR_MIN_LEVEL_DEFAULT));
out.putInt(SbtSettings.KEY_BATTERY_BAR_MAX_LEVEL,
prefs.getInt(SbtSettings.KEY_BATTERY_BAR_MAX_LEVEL, SbtDefaults.BATTERY_BAR_MAX_LEVEL_DEFAULT));
out.putString(SbtSettings.KEY_BATTERY_BAR_DEFAULT_DISCHARGE_COLOR,
prefs.getString(SbtSettings.KEY_BATTERY_BAR_DEFAULT_DISCHARGE_COLOR,
SbtDefaults.BATTERY_BAR_DEFAULT_DISCHARGE_COLOR_DEFAULT));
out.putString(SbtSettings.KEY_BATTERY_BAR_DEFAULT_CHARGE_COLOR,
prefs.getString(SbtSettings.KEY_BATTERY_BAR_DEFAULT_CHARGE_COLOR,
SbtDefaults.BATTERY_BAR_DEFAULT_CHARGE_COLOR_DEFAULT));
out.putStringArrayList(
SbtSettings.KEY_BATTERY_BAR_THRESHOLDS,
new ArrayList<>(prefs.getStringSet(
SbtSettings.KEY_BATTERY_BAR_THRESHOLDS,
java.util.Collections.emptySet())));
out.putBoolean(SbtSettings.KEY_CLOCK_ENABLED,
prefs.getBoolean(SbtSettings.KEY_CLOCK_ENABLED, SbtDefaults.CLOCK_ENABLED_DEFAULT));
out.putBoolean(SbtSettings.KEY_CLOCK_ENABLED_LOCKSCREEN,
prefs.getBoolean(SbtSettings.KEY_CLOCK_ENABLED_LOCKSCREEN, SbtDefaults.CLOCK_ENABLED_LOCKSCREEN_DEFAULT));
out.putBoolean(SbtSettings.KEY_CLOCK_ENABLED_UNLOCKED,
prefs.getBoolean(SbtSettings.KEY_CLOCK_ENABLED_UNLOCKED, SbtDefaults.CLOCK_ENABLED_UNLOCKED_DEFAULT));
out.putString(SbtSettings.KEY_CLOCK_POSITION,
prefs.getString(SbtSettings.KEY_CLOCK_POSITION, SbtDefaults.CLOCK_POSITION_DEFAULT));
out.putInt(SbtSettings.KEY_CLOCK_HORIZONTAL_OFFSET_PX,
prefs.getInt(SbtSettings.KEY_CLOCK_HORIZONTAL_OFFSET_PX, SbtDefaults.CLOCK_HORIZONTAL_OFFSET_PX_DEFAULT));
out.putString(SbtSettings.KEY_CLOCK_MIDDLE_CUTOUT_SIDE,
prefs.getString(SbtSettings.KEY_CLOCK_MIDDLE_CUTOUT_SIDE, SbtDefaults.CLOCK_MIDDLE_CUTOUT_SIDE_DEFAULT));
out.putBoolean(SbtSettings.KEY_CLOCK_MIDDLE_AUTO_NEAREST_SIDE,
prefs.getBoolean(SbtSettings.KEY_CLOCK_MIDDLE_AUTO_NEAREST_SIDE, SbtDefaults.CLOCK_MIDDLE_AUTO_NEAREST_SIDE_DEFAULT));
out.putInt(SbtSettings.KEY_CLOCK_MIDDLE_CUTOUT_GAP_PX,
prefs.getInt(SbtSettings.KEY_CLOCK_MIDDLE_CUTOUT_GAP_PX, SbtDefaults.CLOCK_MIDDLE_CUTOUT_GAP_PX_DEFAULT));
out.putInt(SbtSettings.KEY_CLOCK_VERTICAL_OFFSET_PX,
prefs.getInt(SbtSettings.KEY_CLOCK_VERTICAL_OFFSET_PX, SbtDefaults.CLOCK_VERTICAL_OFFSET_PX_DEFAULT));
out.putBoolean(SbtSettings.KEY_CLOCK_SHOW_SECONDS,
prefs.getBoolean(SbtSettings.KEY_CLOCK_SHOW_SECONDS, SbtDefaults.CLOCK_SHOW_SECONDS_DEFAULT));
out.putBoolean(SbtSettings.KEY_CLOCK_SHOW_DATE_PREFIX,
prefs.getBoolean(SbtSettings.KEY_CLOCK_SHOW_DATE_PREFIX, SbtDefaults.CLOCK_SHOW_DATE_PREFIX_DEFAULT));
out.putBoolean(SbtSettings.KEY_CLOCK_CUSTOM_FORMAT_ENABLED,
prefs.getBoolean(SbtSettings.KEY_CLOCK_CUSTOM_FORMAT_ENABLED, SbtDefaults.CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT));
out.putString(SbtSettings.KEY_CLOCK_CUSTOM_FORMAT,
prefs.getString(SbtSettings.KEY_CLOCK_CUSTOM_FORMAT, SbtDefaults.CLOCK_CUSTOM_FORMAT_DEFAULT));
out.putBoolean(SbtSettings.KEY_CLOCK_IGNORE_UNDER_DISPLAY_CAMERAS,
prefs.getBoolean(SbtSettings.KEY_CLOCK_IGNORE_UNDER_DISPLAY_CAMERAS,
SbtDefaults.CLOCK_IGNORE_UNDER_DISPLAY_CAMERAS_DEFAULT));
out.putBoolean(SbtSettings.KEY_DEBUG_WRITE_LOGS,
prefs.getBoolean(SbtSettings.KEY_DEBUG_WRITE_LOGS,
SbtDefaults.DEBUG_WRITE_LOGS_DEFAULT));
out.putBoolean(SbtSettings.KEY_DEBUG_VISUALIZE_NOTIFICATION_CONTAINER,
prefs.getBoolean(SbtSettings.KEY_DEBUG_VISUALIZE_NOTIFICATION_CONTAINER,
SbtDefaults.DEBUG_VISUALIZE_NOTIFICATION_CONTAINER_DEFAULT));
out.putBoolean(SbtSettings.KEY_DEBUG_VISUALIZE_STATUS_CONTAINER,
prefs.getBoolean(SbtSettings.KEY_DEBUG_VISUALIZE_STATUS_CONTAINER,
SbtDefaults.DEBUG_VISUALIZE_STATUS_CONTAINER_DEFAULT));
out.putBoolean(SbtSettings.KEY_DEBUG_VISUALIZE_CLOCK_CONTAINER,
prefs.getBoolean(SbtSettings.KEY_DEBUG_VISUALIZE_CLOCK_CONTAINER,
SbtDefaults.DEBUG_VISUALIZE_CLOCK_CONTAINER_DEFAULT));
out.putBoolean(SbtSettings.KEY_DEBUG_VISUALIZE_CHIP_CONTAINER,
prefs.getBoolean(SbtSettings.KEY_DEBUG_VISUALIZE_CHIP_CONTAINER,
SbtDefaults.DEBUG_VISUALIZE_CHIP_CONTAINER_DEFAULT));
out.putBoolean(SbtSettings.KEY_DEBUG_VISUALIZE_CAMERA_CUTOUT,
prefs.getBoolean(SbtSettings.KEY_DEBUG_VISUALIZE_CAMERA_CUTOUT,
SbtDefaults.DEBUG_VISUALIZE_CAMERA_CUTOUT_DEFAULT));
out.putInt(SbtSettings.KEY_LAYOUT_PADDING_CUTOUT_PX,
prefs.getInt(SbtSettings.KEY_LAYOUT_PADDING_CUTOUT_PX,
SbtDefaults.LAYOUT_PADDING_CUTOUT_PX_DEFAULT));
out.putInt(SbtSettings.KEY_LAYOUT_PADDING_LEFT_PX,
prefs.getInt(SbtSettings.KEY_LAYOUT_PADDING_LEFT_PX,
SbtDefaults.LAYOUT_PADDING_LEFT_PX_DEFAULT));
out.putInt(SbtSettings.KEY_LAYOUT_PADDING_RIGHT_PX,
prefs.getInt(SbtSettings.KEY_LAYOUT_PADDING_RIGHT_PX,
SbtDefaults.LAYOUT_PADDING_RIGHT_PX_DEFAULT));
out.putInt(SbtSettings.KEY_LAYOUT_ITEM_PADDING_PX,
prefs.getInt(SbtSettings.KEY_LAYOUT_ITEM_PADDING_PX,
SbtDefaults.LAYOUT_ITEM_PADDING_PX_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_PADDING_LEFT_ADD_STOCK,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_PADDING_LEFT_ADD_STOCK,
SbtDefaults.LAYOUT_PADDING_LEFT_ADD_STOCK_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_PADDING_RIGHT_ADD_STOCK,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_PADDING_RIGHT_ADD_STOCK,
SbtDefaults.LAYOUT_PADDING_RIGHT_ADD_STOCK_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_IGNORE_UNDER_DISPLAY_CAMERAS,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_IGNORE_UNDER_DISPLAY_CAMERAS,
SbtDefaults.LAYOUT_IGNORE_UNDER_DISPLAY_CAMERAS_DEFAULT));
out.putString(SbtSettings.KEY_LAYOUT_ITEM_ORDER,
prefs.getString(SbtSettings.KEY_LAYOUT_ITEM_ORDER,
SbtDefaults.LAYOUT_ITEM_ORDER_DEFAULT));
out.putString(SbtSettings.KEY_LAYOUT_CHIP_POSITION,
prefs.getString(SbtSettings.KEY_LAYOUT_CHIP_POSITION,
SbtDefaults.LAYOUT_CHIP_POSITION_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_CHIP_ENABLED_LOCKSCREEN,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_CHIP_ENABLED_LOCKSCREEN,
SbtDefaults.LAYOUT_CHIP_ENABLED_LOCKSCREEN_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_CHIP_ENABLED_UNLOCKED,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_CHIP_ENABLED_UNLOCKED,
SbtDefaults.LAYOUT_CHIP_ENABLED_UNLOCKED_DEFAULT));
out.putString(SbtSettings.KEY_LAYOUT_CHIP_MIDDLE_SIDE,
prefs.getString(SbtSettings.KEY_LAYOUT_CHIP_MIDDLE_SIDE,
SbtDefaults.LAYOUT_CHIP_MIDDLE_SIDE_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_CHIP_MIDDLE_AUTO_NEAREST_SIDE,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_CHIP_MIDDLE_AUTO_NEAREST_SIDE,
SbtDefaults.LAYOUT_CHIP_MIDDLE_AUTO_NEAREST_SIDE_DEFAULT));
out.putInt(SbtSettings.KEY_LAYOUT_CHIP_VERTICAL_OFFSET_PX,
prefs.getInt(SbtSettings.KEY_LAYOUT_CHIP_VERTICAL_OFFSET_PX,
SbtDefaults.LAYOUT_CHIP_VERTICAL_OFFSET_PX_DEFAULT));
out.putString(SbtSettings.KEY_LAYOUT_NOTIF_POSITION,
prefs.getString(SbtSettings.KEY_LAYOUT_NOTIF_POSITION,
SbtDefaults.LAYOUT_NOTIF_POSITION_DEFAULT));
out.putString(SbtSettings.KEY_LAYOUT_NOTIF_SORT_ORDER,
prefs.getString(SbtSettings.KEY_LAYOUT_NOTIF_SORT_ORDER,
SbtDefaults.LAYOUT_NOTIF_SORT_ORDER_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_NOTIF_SHOW_DOT_IF_TRUNCATED,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_NOTIF_SHOW_DOT_IF_TRUNCATED,
SbtDefaults.LAYOUT_NOTIF_SHOW_DOT_IF_TRUNCATED_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_NOTIF_ENABLED_AOD,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_NOTIF_ENABLED_AOD,
SbtDefaults.LAYOUT_NOTIF_ENABLED_AOD_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_NOTIF_ENABLED_LOCKSCREEN,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_NOTIF_ENABLED_LOCKSCREEN,
SbtDefaults.LAYOUT_NOTIF_ENABLED_LOCKSCREEN_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_NOTIF_ENABLED_UNLOCKED,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_NOTIF_ENABLED_UNLOCKED,
SbtDefaults.LAYOUT_NOTIF_ENABLED_UNLOCKED_DEFAULT));
out.putString(SbtSettings.KEY_LAYOUT_NOTIF_MIDDLE_SIDE,
prefs.getString(SbtSettings.KEY_LAYOUT_NOTIF_MIDDLE_SIDE,
SbtDefaults.LAYOUT_NOTIF_MIDDLE_SIDE_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_NOTIF_MIDDLE_AUTO_NEAREST_SIDE,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_NOTIF_MIDDLE_AUTO_NEAREST_SIDE,
SbtDefaults.LAYOUT_NOTIF_MIDDLE_AUTO_NEAREST_SIDE_DEFAULT));
out.putInt(SbtSettings.KEY_LAYOUT_NOTIF_VERTICAL_OFFSET_PX,
prefs.getInt(SbtSettings.KEY_LAYOUT_NOTIF_VERTICAL_OFFSET_PX,
SbtDefaults.LAYOUT_NOTIF_VERTICAL_OFFSET_PX_DEFAULT));
out.putString(SbtSettings.KEY_LAYOUT_STATUS_POSITION,
prefs.getString(SbtSettings.KEY_LAYOUT_STATUS_POSITION,
SbtDefaults.LAYOUT_STATUS_POSITION_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_STATUS_ENABLED_AOD,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_STATUS_ENABLED_AOD,
SbtDefaults.LAYOUT_STATUS_ENABLED_AOD_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_STATUS_ENABLED_LOCKSCREEN,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_STATUS_ENABLED_LOCKSCREEN,
SbtDefaults.LAYOUT_STATUS_ENABLED_LOCKSCREEN_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_STATUS_ENABLED_UNLOCKED,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_STATUS_ENABLED_UNLOCKED,
SbtDefaults.LAYOUT_STATUS_ENABLED_UNLOCKED_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_STATUS_SHOW_DOT_IF_TRUNCATED,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_STATUS_SHOW_DOT_IF_TRUNCATED,
SbtDefaults.LAYOUT_STATUS_SHOW_DOT_IF_TRUNCATED_DEFAULT));
out.putString(SbtSettings.KEY_LAYOUT_STATUS_MIDDLE_SIDE,
prefs.getString(SbtSettings.KEY_LAYOUT_STATUS_MIDDLE_SIDE,
SbtDefaults.LAYOUT_STATUS_MIDDLE_SIDE_DEFAULT));
out.putBoolean(SbtSettings.KEY_LAYOUT_STATUS_MIDDLE_AUTO_NEAREST_SIDE,
prefs.getBoolean(SbtSettings.KEY_LAYOUT_STATUS_MIDDLE_AUTO_NEAREST_SIDE,
SbtDefaults.LAYOUT_STATUS_MIDDLE_AUTO_NEAREST_SIDE_DEFAULT));
out.putInt(SbtSettings.KEY_LAYOUT_STATUS_VERTICAL_OFFSET_PX,
prefs.getInt(SbtSettings.KEY_LAYOUT_STATUS_VERTICAL_OFFSET_PX,
SbtDefaults.LAYOUT_STATUS_VERTICAL_OFFSET_PX_DEFAULT));
out.putStringArrayList(
SbtSettings.KEY_CLOCK_CAMERA_TYPE_OVERRIDES,
new ArrayList<>(prefs.getStringSet(
SbtSettings.KEY_CLOCK_CAMERA_TYPE_OVERRIDES,
java.util.Collections.emptySet())));
out.putStringArrayList(
SbtSettings.KEY_SYSTEM_ICON_BLOCKED_MODES,
new ArrayList<>(prefs.getStringSet(
SbtSettings.KEY_SYSTEM_ICON_BLOCKED_MODES,
java.util.Collections.emptySet())));
out.putStringArrayList(
SbtSettings.KEY_SYSTEM_ICON_HIDE_SLOTS,
new ArrayList<>(prefs.getStringSet(
SbtSettings.KEY_SYSTEM_ICON_HIDE_SLOTS,
java.util.Collections.emptySet())));
out.putBoolean(SbtSettings.KEY_STATUS_CHIPS_HIDE_ALL,
prefs.getBoolean(SbtSettings.KEY_STATUS_CHIPS_HIDE_ALL, false));
out.putBoolean(SbtSettings.KEY_STATUS_CHIPS_HIDE_MEDIA_UNLOCKED,
prefs.getBoolean(SbtSettings.KEY_STATUS_CHIPS_HIDE_MEDIA_UNLOCKED, false));
out.putBoolean(SbtSettings.KEY_STATUS_CHIPS_HIDE_NAV_UNLOCKED,
prefs.getBoolean(SbtSettings.KEY_STATUS_CHIPS_HIDE_NAV_UNLOCKED, false));
out.putBoolean(SbtSettings.KEY_STATUS_CHIPS_HIDE_CALL,
prefs.getBoolean(SbtSettings.KEY_STATUS_CHIPS_HIDE_CALL, false));
out.putStringArrayList(
SbtSettings.KEY_APP_ICON_BLOCKED_MODES,
new ArrayList<>(AppIconRules.encodeBlockedModes(AppIconRules.loadBlockedModes(prefs))));
out.putStringArrayList(
SbtSettings.KEY_APP_ICON_EXCEPTION_RULES,
new ArrayList<>(AppIconRules.encodeExceptionRules(AppIconRules.loadExceptionRules(prefs))));
return out;
}
if (METHOD_NOTE_SEEN_ICON_APP.equals(method)) {
String pkg = extras != null ? extras.getString(AppIconRules.EXTRA_PACKAGE) : null;
long firstSeenMs = extras != null
? extras.getLong(AppIconRules.EXTRA_FIRST_SEEN_MS, System.currentTimeMillis())
: System.currentTimeMillis();
if (!AppIconRules.isValidPackageName(pkg)) {
return Bundle.EMPTY;
}
synchronized (LOCK) {
Map<String, Long> seen = AppIconRules.loadSeenSession(prefs);
AppIconRules.noteSeen(seen, pkg, firstSeenMs);
AppIconRules.saveSeenSession(prefs, seen);
}
return Bundle.EMPTY;
}
if (METHOD_SET_CLOCK_CAMERA_DEBUG_STATE.equals(method)) {
String cameraId = extras != null ? extras.getString(SbtSettings.KEY_DEBUG_CURRENT_CAMERA_ID, "") : "";
String autoType = extras != null ? extras.getString(SbtSettings.KEY_DEBUG_CURRENT_CAMERA_AUTO_TYPE, "") : "";
String effectiveType = extras != null ? extras.getString(SbtSettings.KEY_DEBUG_CURRENT_CAMERA_EFFECTIVE_TYPE, "") : "";
prefs.edit()
.putString(SbtSettings.KEY_DEBUG_CURRENT_CAMERA_ID, cameraId)
.putString(SbtSettings.KEY_DEBUG_CURRENT_CAMERA_AUTO_TYPE, autoType)
.putString(SbtSettings.KEY_DEBUG_CURRENT_CAMERA_EFFECTIVE_TYPE, effectiveType)
.apply();
return Bundle.EMPTY;
}
return super.call(method, arg, extras);
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
return null;
}
@Override
public String getType(Uri uri) {
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
return null;
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
return 0;
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
return 0;
}
}
@@ -0,0 +1,150 @@
package se.ajpanton.statusbartweak.shell.settings;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
public final class SystemIconRules {
public static final String PREF_KEY_BLOCKED_MODES = "system_icon_blocked_modes";
public static final int MODE_AOD = 1;
public static final int MODE_LOCK = 1 << 1;
public static final int MODE_UNLOCK = 1 << 2;
private static final String MODE_NAME_AOD = "AOD";
private static final String MODE_NAME_LOCK = "LOCK";
private static final String MODE_NAME_UNLOCK = "UNLOCK";
private static final String ENTRY_SEPARATOR = "|";
private SystemIconRules() {
}
public static Map<String, Integer> parseBlockedModes(Set<String> encoded) {
if (encoded == null || encoded.isEmpty()) {
return Collections.emptyMap();
}
HashMap<String, Integer> out = new HashMap<>();
for (String entry : encoded) {
if (entry == null || entry.trim().isEmpty()) {
continue;
}
String[] parts = entry.split("\\|", 2);
if (parts.length != 2) {
continue;
}
String slot = normalizeSlot(parts[0]);
if (slot == null) {
continue;
}
int mask;
try {
mask = Integer.parseInt(parts[1].trim());
} catch (Throwable ignored) {
continue;
}
mask &= (MODE_AOD | MODE_LOCK | MODE_UNLOCK);
if (mask == 0) {
continue;
}
Integer existing = out.get(slot);
out.put(slot, existing == null ? mask : (existing | mask));
}
return out.isEmpty() ? Collections.emptyMap() : out;
}
public static Set<String> encodeBlockedModes(Map<String, Integer> blockedModes) {
if (blockedModes == null || blockedModes.isEmpty()) {
return Collections.emptySet();
}
HashSet<String> out = new HashSet<>();
for (Map.Entry<String, Integer> entry : blockedModes.entrySet()) {
String slot = normalizeSlot(entry.getKey());
if (slot == null) {
continue;
}
Integer maskObj = entry.getValue();
if (maskObj == null) {
continue;
}
int mask = maskObj & (MODE_AOD | MODE_LOCK | MODE_UNLOCK);
if (mask == 0) {
continue;
}
out.add(slot + ENTRY_SEPARATOR + mask);
}
return out.isEmpty() ? Collections.emptySet() : out;
}
public static boolean isBlocked(Map<String, Integer> blockedModes, String slot, String mode) {
if (blockedModes == null || blockedModes.isEmpty()) {
return false;
}
String normalizedSlot = normalizeSlot(slot);
if (normalizedSlot == null) {
return false;
}
int modeMask = modeMaskFromName(mode);
if (modeMask == 0) {
return false;
}
Integer maskObj = blockedModes.get(normalizedSlot);
int mask = maskObj == null ? 0 : maskObj;
return (mask & modeMask) != 0;
}
public static void setModeBlocked(Map<String, Integer> blockedModes,
String slot,
int modeMask,
boolean blocked) {
if (blockedModes == null) {
return;
}
String normalizedSlot = normalizeSlot(slot);
if (normalizedSlot == null) {
return;
}
int validModeMask = modeMask & (MODE_AOD | MODE_LOCK | MODE_UNLOCK);
if (validModeMask == 0) {
return;
}
Integer currentObj = blockedModes.get(normalizedSlot);
int current = currentObj == null ? 0 : currentObj;
int next = blocked ? (current | validModeMask) : (current & ~validModeMask);
if (next == 0) {
blockedModes.remove(normalizedSlot);
} else {
blockedModes.put(normalizedSlot, next);
}
}
public static int modeMaskFromName(String mode) {
if (mode == null) {
return 0;
}
String normalized = mode.trim().toUpperCase(Locale.ROOT);
if (MODE_NAME_AOD.equals(normalized)) {
return MODE_AOD;
}
if (MODE_NAME_LOCK.equals(normalized) || "LOCKSCREEN".equals(normalized)) {
return MODE_LOCK;
}
if (MODE_NAME_UNLOCK.equals(normalized) || "UNLOCKED".equals(normalized)) {
return MODE_UNLOCK;
}
return 0;
}
private static String normalizeSlot(String slot) {
if (slot == null) {
return null;
}
String normalized = slot.trim();
if (normalized.isEmpty()) {
return null;
}
return normalized;
}
}
@@ -0,0 +1,872 @@
package se.ajpanton.statusbartweak.shell.ui;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.text.Editable;
import android.text.InputType;
import android.text.TextWatcher;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.slider.RangeSlider;
import com.google.android.material.switchmaterial.SwitchMaterial;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import se.ajpanton.statusbartweak.R;
import se.ajpanton.statusbartweak.shell.settings.BatteryBarStyle;
import se.ajpanton.statusbartweak.shell.settings.SbtDefaults;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
public final class BatteryBarFragment extends Fragment {
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_battery_bar, container, false);
SharedPreferences prefs = requireContext()
.getSharedPreferences(SbtSettings.PREFS_NAME, Context.MODE_PRIVATE);
SwitchMaterial enabled = root.findViewById(R.id.battery_bar_enabled_switch);
RadioButton positionTop = root.findViewById(R.id.battery_bar_position_top);
RadioButton positionBottom = root.findViewById(R.id.battery_bar_position_bottom);
RadioGroup positionGroup = root.findViewById(R.id.battery_bar_position_group);
View rtlCell = root.findViewById(R.id.battery_bar_alignment_cell_rtl);
View centreCell = root.findViewById(R.id.battery_bar_alignment_cell_center);
View ltrCell = root.findViewById(R.id.battery_bar_alignment_cell_ltr);
RadioButton alignLtr = root.findViewById(R.id.battery_bar_alignment_ltr);
RadioButton alignRtl = root.findViewById(R.id.battery_bar_alignment_rtl);
RadioButton alignCentre = root.findViewById(R.id.battery_bar_alignment_center);
EditText thicknessInput = root.findViewById(R.id.battery_bar_thickness_input);
MaterialButton thicknessMinus = root.findViewById(R.id.battery_bar_thickness_minus);
MaterialButton thicknessPlus = root.findViewById(R.id.battery_bar_thickness_plus);
EditText edgeOffsetInput = root.findViewById(R.id.battery_bar_edge_offset_input);
MaterialButton edgeOffsetMinus = root.findViewById(R.id.battery_bar_edge_offset_minus);
MaterialButton edgeOffsetPlus = root.findViewById(R.id.battery_bar_edge_offset_plus);
MaterialButton minLevelMinus = root.findViewById(R.id.battery_bar_min_level_minus);
MaterialButton minLevelPlus = root.findViewById(R.id.battery_bar_min_level_plus);
MaterialButton maxLevelMinus = root.findViewById(R.id.battery_bar_max_level_minus);
MaterialButton maxLevelPlus = root.findViewById(R.id.battery_bar_max_level_plus);
RangeSlider mappingSlider = root.findViewById(R.id.battery_bar_mapping_slider);
TextView minLevelLabel = root.findViewById(R.id.battery_bar_min_level_label);
TextView maxLevelLabel = root.findViewById(R.id.battery_bar_max_level_label);
MaterialButton defaultDischargeButton = root.findViewById(R.id.battery_bar_default_discharge_button);
MaterialButton defaultChargeButton = root.findViewById(R.id.battery_bar_default_charge_button);
MaterialButton addThresholdButton = root.findViewById(R.id.battery_bar_add_threshold_button);
LinearLayout thresholdsContainer = root.findViewById(R.id.battery_bar_thresholds_container);
View geometrySection = root.findViewById(R.id.battery_bar_geometry_section);
mappingSlider.setValueFrom(0f);
mappingSlider.setValueTo(100f);
mappingSlider.setStepSize(1f);
mappingSlider.setMinSeparationValue(1f);
trimMappingStepButton(minLevelMinus);
trimMappingStepButton(minLevelPlus);
trimMappingStepButton(maxLevelMinus);
trimMappingStepButton(maxLevelPlus);
enabled.setChecked(prefs.getBoolean(
SbtSettings.KEY_BATTERY_BAR_ENABLED,
SbtDefaults.BATTERY_BAR_ENABLED_DEFAULT));
applyPositionSelection(
positionTop,
positionBottom,
prefs.getString(
SbtSettings.KEY_BATTERY_BAR_POSITION,
SbtDefaults.BATTERY_BAR_POSITION_DEFAULT));
applyAlignmentSelection(
alignLtr,
alignRtl,
alignCentre,
prefs.getString(
SbtSettings.KEY_BATTERY_BAR_ALIGNMENT,
SbtDefaults.BATTERY_BAR_ALIGNMENT_DEFAULT));
setIntInput(thicknessInput, prefs.getInt(
SbtSettings.KEY_BATTERY_BAR_THICKNESS_DP,
SbtDefaults.BATTERY_BAR_THICKNESS_DP_DEFAULT));
setIntInput(edgeOffsetInput, prefs.getInt(
SbtSettings.KEY_BATTERY_BAR_EDGE_OFFSET_DP,
SbtDefaults.BATTERY_BAR_EDGE_OFFSET_DP_DEFAULT));
setMapping(
prefs.getInt(SbtSettings.KEY_BATTERY_BAR_MIN_LEVEL, SbtDefaults.BATTERY_BAR_MIN_LEVEL_DEFAULT),
prefs.getInt(SbtSettings.KEY_BATTERY_BAR_MAX_LEVEL, SbtDefaults.BATTERY_BAR_MAX_LEVEL_DEFAULT),
mappingSlider,
minLevelLabel,
maxLevelLabel,
false);
setSectionEnabled(geometrySection, enabled.isChecked());
updateColourButtonLabel(
defaultDischargeButton,
R.string.battery_bar_default_discharge_colour,
prefs.getString(
SbtSettings.KEY_BATTERY_BAR_DEFAULT_DISCHARGE_COLOR,
SbtDefaults.BATTERY_BAR_DEFAULT_DISCHARGE_COLOR_DEFAULT),
R.string.battery_bar_colour_auto);
updateColourButtonLabel(
defaultChargeButton,
R.string.battery_bar_default_charge_colour,
prefs.getString(
SbtSettings.KEY_BATTERY_BAR_DEFAULT_CHARGE_COLOR,
SbtDefaults.BATTERY_BAR_DEFAULT_CHARGE_COLOR_DEFAULT),
R.string.battery_bar_colour_same_as_default);
enabled.setOnCheckedChangeListener((buttonView, isChecked) -> {
prefs.edit().putBoolean(SbtSettings.KEY_BATTERY_BAR_ENABLED, isChecked).apply();
SbtSettings.ensureReadable(requireContext());
setSectionEnabled(geometrySection, isChecked);
});
positionGroup.setOnCheckedChangeListener((group, checkedId) -> {
prefs.edit()
.putString(SbtSettings.KEY_BATTERY_BAR_POSITION, positionValue(checkedId))
.apply();
SbtSettings.ensureReadable(requireContext());
});
bindAlignmentCell(rtlCell, alignRtl, alignCentre, alignLtr, "rtl", prefs);
bindAlignmentCell(centreCell, alignRtl, alignCentre, alignLtr, "center", prefs);
bindAlignmentCell(ltrCell, alignRtl, alignCentre, alignLtr, "ltr", prefs);
bindAlignmentCell(alignRtl, alignRtl, alignCentre, alignLtr, "rtl", prefs);
bindAlignmentCell(alignCentre, alignRtl, alignCentre, alignLtr, "center", prefs);
bindAlignmentCell(alignLtr, alignRtl, alignCentre, alignLtr, "ltr", prefs);
bindNumberControl(
prefs,
thicknessInput,
thicknessMinus,
thicknessPlus,
SbtSettings.KEY_BATTERY_BAR_THICKNESS_DP,
SbtDefaults.BATTERY_BAR_THICKNESS_DP_MIN,
SbtDefaults.BATTERY_BAR_THICKNESS_DP_MAX,
SbtDefaults.BATTERY_BAR_THICKNESS_DP_DEFAULT);
bindNumberControl(
prefs,
edgeOffsetInput,
edgeOffsetMinus,
edgeOffsetPlus,
SbtSettings.KEY_BATTERY_BAR_EDGE_OFFSET_DP,
SbtDefaults.BATTERY_BAR_EDGE_OFFSET_DP_MIN,
SbtDefaults.BATTERY_BAR_EDGE_OFFSET_DP_MAX,
SbtDefaults.BATTERY_BAR_EDGE_OFFSET_DP_DEFAULT);
mappingSlider.addOnChangeListener((slider, value, fromUser) -> {
if (!fromUser) {
return;
}
List<Float> values = slider.getValues();
if (values.size() < 2) {
return;
}
persistMapping(prefs, Math.round(values.get(0)), Math.round(values.get(1)),
mappingSlider, minLevelLabel, maxLevelLabel);
});
minLevelPlus.setOnClickListener(v -> adjustMapping(prefs, mappingSlider, minLevelLabel, maxLevelLabel, true, 1));
minLevelMinus.setOnClickListener(v -> adjustMapping(prefs, mappingSlider, minLevelLabel, maxLevelLabel, true, -1));
maxLevelPlus.setOnClickListener(v -> adjustMapping(prefs, mappingSlider, minLevelLabel, maxLevelLabel, false, 1));
maxLevelMinus.setOnClickListener(v -> adjustMapping(prefs, mappingSlider, minLevelLabel, maxLevelLabel, false, -1));
defaultDischargeButton.setOnClickListener(v -> showColourDialog(
SbtSettings.KEY_BATTERY_BAR_DEFAULT_DISCHARGE_COLOR,
R.string.battery_bar_default_discharge_colour,
R.string.battery_bar_colour_auto,
prefs,
defaultDischargeButton));
defaultChargeButton.setOnClickListener(v -> showColourDialog(
SbtSettings.KEY_BATTERY_BAR_DEFAULT_CHARGE_COLOR,
R.string.battery_bar_default_charge_colour,
R.string.battery_bar_colour_same_as_discharge,
prefs,
defaultChargeButton));
addThresholdButton.setOnClickListener(v -> showThresholdPercentDialog(
null,
prefs,
thresholdsContainer,
percent -> {
ArrayList<BatteryBarStyle.Threshold> thresholds = loadThresholds(prefs);
thresholds.add(new BatteryBarStyle.Threshold(
percent,
prefs.getString(
SbtSettings.KEY_BATTERY_BAR_DEFAULT_DISCHARGE_COLOR,
SbtDefaults.BATTERY_BAR_DEFAULT_DISCHARGE_COLOR_DEFAULT),
BatteryBarStyle.THRESHOLD_CHARGE_USE_DEFAULT));
persistThresholds(prefs, thresholds);
renderThresholdRows(thresholdsContainer, prefs);
}));
renderThresholdRows(thresholdsContainer, prefs);
return root;
}
private void bindAlignmentCell(View clickTarget,
RadioButton alignRtl,
RadioButton alignCentre,
RadioButton alignLtr,
String value,
SharedPreferences prefs) {
if (clickTarget == null) {
return;
}
clickTarget.setOnClickListener(v -> {
applyAlignmentSelection(alignLtr, alignRtl, alignCentre, value);
prefs.edit().putString(SbtSettings.KEY_BATTERY_BAR_ALIGNMENT, value).apply();
SbtSettings.ensureReadable(requireContext());
});
}
private void updateColourButtonLabel(MaterialButton button,
int labelRes,
String colour,
int emptyLabelRes) {
if (button == null) {
return;
}
String normalised = BatteryBarStyle.normalizeColor(colour);
String value;
if (BatteryBarStyle.isThresholdChargeDefault(colour)) {
value = getString(R.string.battery_bar_colour_same_as_default);
} else if (normalised.isEmpty()) {
value = getString(emptyLabelRes);
} else {
value = normalised;
}
button.setText(getString(labelRes) + ": " + value);
}
private void trimMappingStepButton(MaterialButton button) {
if (button == null) {
return;
}
button.setMinHeight(0);
button.setMinimumHeight(0);
button.setPadding(button.getPaddingLeft(), 0, button.getPaddingRight(), 0);
tryInvokeIntMethod(button, "setInsetTop", 0);
tryInvokeIntMethod(button, "setInsetBottom", 0);
}
private void tryInvokeIntMethod(Object target, String methodName, int value) {
if (target == null || methodName == null) {
return;
}
try {
java.lang.reflect.Method method = target.getClass().getMethod(methodName, int.class);
method.invoke(target, value);
} catch (Throwable ignored) {
}
}
private void setSectionEnabled(View view, boolean enabled) {
if (view == null) {
return;
}
view.setAlpha(enabled ? 1f : 0.82f);
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int i = 0; i < group.getChildCount(); i++) {
setSectionEnabled(group.getChildAt(i), enabled);
}
} else {
if (view.getClass() == TextView.class) {
view.setEnabled(true);
} else {
view.setEnabled(enabled);
}
}
}
private void bindNumberControl(SharedPreferences prefs,
EditText input,
MaterialButton minus,
MaterialButton plus,
String key,
int min,
int max,
int fallback) {
final boolean[] updating = new boolean[]{false};
if (minus != null) {
minus.setOnClickListener(v -> {
int current = parseIntInput(input, prefs.getInt(key, fallback));
persistInt(prefs, key, clampInt(current - 1, min, max), input, updating);
});
}
if (plus != null) {
plus.setOnClickListener(v -> {
int current = parseIntInput(input, prefs.getInt(key, fallback));
persistInt(prefs, key, clampInt(current + 1, min, max), input, updating);
});
}
if (input != null) {
input.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
if (updating[0] || s == null) {
return;
}
String text = s.toString().trim();
if (text.isEmpty()) {
return;
}
int current = parseIntInput(input, prefs.getInt(key, fallback));
persistInt(prefs, key, clampInt(current, min, max), input, updating);
}
});
input.setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus) {
return;
}
int current = parseIntInput(input, prefs.getInt(key, fallback));
persistInt(prefs, key, clampInt(current, min, max), input, updating);
});
}
}
private void adjustMapping(SharedPreferences prefs,
RangeSlider slider,
TextView minLabel,
TextView maxLabel,
boolean lower,
int delta) {
List<Float> values = slider.getValues();
if (values.size() < 2) {
return;
}
int min = Math.round(values.get(0));
int max = Math.round(values.get(1));
if (lower) {
min = clampInt(min + delta, SbtDefaults.BATTERY_BAR_MIN_LEVEL_MIN, max - 1);
} else {
max = clampInt(max + delta, min + 1, SbtDefaults.BATTERY_BAR_MAX_LEVEL_MAX);
}
persistMapping(prefs, min, max, slider, minLabel, maxLabel);
}
private void persistMapping(SharedPreferences prefs,
int min,
int max,
RangeSlider slider,
TextView minLabel,
TextView maxLabel) {
min = clampInt(min, SbtDefaults.BATTERY_BAR_MIN_LEVEL_MIN, SbtDefaults.BATTERY_BAR_MIN_LEVEL_MAX);
max = clampInt(max, SbtDefaults.BATTERY_BAR_MAX_LEVEL_MIN, SbtDefaults.BATTERY_BAR_MAX_LEVEL_MAX);
if (max <= min) {
max = Math.min(SbtDefaults.BATTERY_BAR_MAX_LEVEL_MAX, min + 1);
if (max <= min) {
min = Math.max(SbtDefaults.BATTERY_BAR_MIN_LEVEL_MIN, max - 1);
}
}
prefs.edit()
.putInt(SbtSettings.KEY_BATTERY_BAR_MIN_LEVEL, min)
.putInt(SbtSettings.KEY_BATTERY_BAR_MAX_LEVEL, max)
.apply();
SbtSettings.ensureReadable(requireContext());
setMapping(min, max, slider, minLabel, maxLabel, true);
}
private void setMapping(int min,
int max,
RangeSlider slider,
TextView minLabel,
TextView maxLabel,
boolean forceSlider) {
if (slider != null) {
List<Float> current = slider.getValues();
boolean different = current.size() < 2
|| Math.round(current.get(0)) != min
|| Math.round(current.get(1)) != max;
if (forceSlider || different) {
slider.setValues((float) min, (float) max);
}
}
if (minLabel != null) {
minLabel.setText(getString(R.string.battery_bar_mapping_min_label, min));
}
if (maxLabel != null) {
maxLabel.setText(getString(R.string.battery_bar_mapping_max_label, max));
}
}
private void persistInt(SharedPreferences prefs,
String key,
int value,
EditText input,
boolean[] updating) {
prefs.edit().putInt(key, value).apply();
SbtSettings.ensureReadable(requireContext());
if (updating != null) {
updating[0] = true;
}
try {
setIntInput(input, value);
} finally {
if (updating != null) {
updating[0] = false;
}
}
}
private void persistThresholds(SharedPreferences prefs,
ArrayList<BatteryBarStyle.Threshold> thresholds) {
prefs.edit()
.putStringSet(
SbtSettings.KEY_BATTERY_BAR_THRESHOLDS,
new LinkedHashSet<>(BatteryBarStyle.encodeThresholds(thresholds)))
.apply();
SbtSettings.ensureReadable(requireContext());
}
private ArrayList<BatteryBarStyle.Threshold> loadThresholds(SharedPreferences prefs) {
return BatteryBarStyle.decodeThresholds(prefs.getStringSet(
SbtSettings.KEY_BATTERY_BAR_THRESHOLDS,
java.util.Collections.emptySet()));
}
private ArrayList<BatteryBarStyle.Threshold> sortedThresholdsForUi(SharedPreferences prefs) {
ArrayList<BatteryBarStyle.Threshold> thresholds = loadThresholds(prefs);
thresholds.sort((a, b) -> Integer.compare(b.percent, a.percent));
return thresholds;
}
private void renderThresholdRows(LinearLayout container, SharedPreferences prefs) {
if (container == null) {
return;
}
Context context = container.getContext();
container.removeAllViews();
ArrayList<BatteryBarStyle.Threshold> thresholds = sortedThresholdsForUi(prefs);
for (BatteryBarStyle.Threshold threshold : thresholds) {
final int originalPercent = threshold.percent;
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.VERTICAL);
row.setBackgroundResource(R.drawable.sbt_threshold_card);
int padding = dpToPx(context, 12);
row.setPadding(padding, padding, padding, padding);
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
rowParams.topMargin = dpToPx(context, 12);
row.setLayoutParams(rowParams);
TextView title = new TextView(context);
title.setText(getString(R.string.battery_bar_threshold_label, threshold.percent));
row.addView(title);
MaterialButton percentButton = createRowButton(context);
percentButton.setText(getString(R.string.battery_bar_threshold_change_percent));
percentButton.setOnClickListener(v -> showThresholdPercentDialog(
threshold,
prefs,
container,
newPercent -> {
ArrayList<BatteryBarStyle.Threshold> updated = loadThresholds(prefs);
if (containsThreshold(updated, newPercent, originalPercent)) {
showMessageDialog(R.string.battery_bar_threshold_duplicate);
return;
}
int oldIndex = indexOfPercent(sortedThresholdsForUi(prefs), originalPercent);
replaceThresholdPercent(updated, originalPercent, newPercent);
persistThresholds(prefs, updated);
ArrayList<BatteryBarStyle.Threshold> sortedUpdated = sortedThresholdsForUi(prefs);
int newIndex = indexOfPercent(sortedUpdated, newPercent);
renderThresholdRows(container, prefs);
if (oldIndex != -1 && newIndex != -1 && oldIndex != newIndex) {
showMessageDialog(R.string.battery_bar_threshold_reordered);
}
}));
row.addView(percentButton);
MaterialButton dischargeButton = createRowButton(context);
updateColourButtonLabel(
dischargeButton,
R.string.battery_bar_threshold_discharge_colour,
threshold.dischargeColor,
R.string.battery_bar_colour_auto);
dischargeButton.setOnClickListener(v -> showThresholdColourDialog(
threshold,
true,
prefs,
container));
row.addView(dischargeButton);
MaterialButton chargeButton = createRowButton(context);
updateColourButtonLabel(
chargeButton,
R.string.battery_bar_threshold_charge_colour,
threshold.chargeColor,
R.string.battery_bar_colour_same_as_default);
chargeButton.setOnClickListener(v -> showThresholdColourDialog(
threshold,
false,
prefs,
container));
row.addView(chargeButton);
MaterialButton removeButton = createRowButton(context);
removeButton.setText(getString(R.string.battery_bar_threshold_remove));
removeButton.setOnClickListener(v -> {
ArrayList<BatteryBarStyle.Threshold> updated = loadThresholds(prefs);
removeThreshold(updated, originalPercent);
persistThresholds(prefs, updated);
renderThresholdRows(container, prefs);
});
row.addView(removeButton);
container.addView(row);
}
}
private MaterialButton createRowButton(Context context) {
MaterialButton button = new MaterialButton(
context,
null,
com.google.android.material.R.attr.materialButtonOutlinedStyle);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
params.topMargin = dpToPx(context, 8);
button.setLayoutParams(params);
return button;
}
private boolean containsThreshold(ArrayList<BatteryBarStyle.Threshold> thresholds,
int percent,
int ignorePercent) {
for (BatteryBarStyle.Threshold threshold : thresholds) {
if (threshold.percent == percent && threshold.percent != ignorePercent) {
return true;
}
}
return false;
}
private int indexOfPercent(ArrayList<BatteryBarStyle.Threshold> thresholds, int percent) {
for (int i = 0; i < thresholds.size(); i++) {
if (thresholds.get(i).percent == percent) {
return i;
}
}
return -1;
}
private void replaceThresholdPercent(ArrayList<BatteryBarStyle.Threshold> thresholds,
int oldPercent,
int newPercent) {
for (int i = 0; i < thresholds.size(); i++) {
BatteryBarStyle.Threshold threshold = thresholds.get(i);
if (threshold.percent == oldPercent) {
thresholds.set(i, new BatteryBarStyle.Threshold(
newPercent,
threshold.dischargeColor,
threshold.chargeColor));
return;
}
}
}
private void removeThreshold(ArrayList<BatteryBarStyle.Threshold> thresholds, int percent) {
for (int i = 0; i < thresholds.size(); i++) {
if (thresholds.get(i).percent == percent) {
thresholds.remove(i);
return;
}
}
}
private void showThresholdColourDialog(BatteryBarStyle.Threshold threshold,
boolean discharge,
SharedPreferences prefs,
LinearLayout container) {
showColourDialog(
discharge
? R.string.battery_bar_threshold_discharge_colour
: R.string.battery_bar_threshold_charge_colour,
discharge ? threshold.dischargeColor : threshold.chargeColor,
discharge
? R.string.battery_bar_colour_auto
: R.string.battery_bar_colour_same_as_discharge,
!discharge,
colour -> {
ArrayList<BatteryBarStyle.Threshold> updated = loadThresholds(prefs);
for (int i = 0; i < updated.size(); i++) {
BatteryBarStyle.Threshold current = updated.get(i);
if (current.percent != threshold.percent) {
continue;
}
updated.set(i, new BatteryBarStyle.Threshold(
current.percent,
discharge ? colour : current.dischargeColor,
discharge ? current.chargeColor : colour));
break;
}
persistThresholds(prefs, updated);
renderThresholdRows(container, prefs);
});
}
private void showColourDialog(String key,
int titleRes,
int emptyLabelRes,
SharedPreferences prefs,
MaterialButton button) {
showColourDialog(
titleRes,
prefs.getString(key, ""),
emptyLabelRes,
false,
colour -> {
prefs.edit().putString(key, colour).apply();
SbtSettings.ensureReadable(requireContext());
updateColourButtonLabel(button, titleRes, colour, emptyLabelRes);
});
}
private void showColourDialog(int titleRes,
String initialValue,
int emptyLabelRes,
boolean allowSameAsDefault,
ColourConsumer consumer) {
Context context = requireContext();
LinearLayout layout = new LinearLayout(context);
layout.setOrientation(LinearLayout.VERTICAL);
layout.setPadding(
dpToPx(context, 24),
dpToPx(context, 8),
dpToPx(context, 24),
0);
EditText input = new EditText(context);
input.setInputType(InputType.TYPE_CLASS_TEXT);
input.setSingleLine(true);
input.setHint(getString(R.string.battery_bar_colour_dialog_hint));
String normalised = BatteryBarStyle.normalizeColor(initialValue);
input.setText(normalised);
layout.addView(input, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT));
MaterialButton sameAsDischargeButton = null;
MaterialButton sameAsDefaultButton = null;
if (allowSameAsDefault) {
TextView note = new TextView(context);
note.setText(R.string.battery_bar_threshold_charge_note);
LinearLayout.LayoutParams noteParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
noteParams.topMargin = dpToPx(context, 8);
note.setLayoutParams(noteParams);
note.setGravity(Gravity.START);
layout.addView(note);
sameAsDischargeButton = new MaterialButton(
context,
null,
com.google.android.material.R.attr.materialButtonOutlinedStyle);
sameAsDischargeButton.setText(R.string.battery_bar_colour_same_as_discharge);
LinearLayout.LayoutParams dischargeParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
dischargeParams.topMargin = dpToPx(context, 8);
sameAsDischargeButton.setLayoutParams(dischargeParams);
layout.addView(sameAsDischargeButton);
sameAsDefaultButton = new MaterialButton(
context,
null,
com.google.android.material.R.attr.materialButtonOutlinedStyle);
sameAsDefaultButton.setText(R.string.battery_bar_colour_same_as_default);
LinearLayout.LayoutParams defaultParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
defaultParams.topMargin = dpToPx(context, 8);
sameAsDefaultButton.setLayoutParams(defaultParams);
layout.addView(sameAsDefaultButton);
}
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(context)
.setTitle(titleRes)
.setView(layout)
.setNegativeButton(android.R.string.cancel, null)
.setNeutralButton(allowSameAsDefault ? null : getString(emptyLabelRes),
(dialogInterface, which) -> consumer.accept(""))
.setPositiveButton(android.R.string.ok, (dialogInterface, which) -> {
String raw = input.getText() != null ? input.getText().toString() : "";
consumer.accept(BatteryBarStyle.normalizeColor(raw));
})
.show();
if (sameAsDischargeButton != null) {
MaterialButton finalSameAsDischargeButton = sameAsDischargeButton;
finalSameAsDischargeButton.setOnClickListener(v -> {
consumer.accept("");
dialog.dismiss();
});
}
if (sameAsDefaultButton != null) {
MaterialButton finalSameAsDefaultButton = sameAsDefaultButton;
finalSameAsDefaultButton.setOnClickListener(v -> {
consumer.accept(BatteryBarStyle.THRESHOLD_CHARGE_USE_DEFAULT);
dialog.dismiss();
});
}
focusInput(dialog, input);
}
private void showThresholdPercentDialog(@Nullable BatteryBarStyle.Threshold existing,
SharedPreferences prefs,
LinearLayout container,
IntConsumer consumer) {
Context context = requireContext();
EditText input = new EditText(context);
input.setInputType(InputType.TYPE_CLASS_NUMBER);
input.setSingleLine(true);
input.setHint(getString(R.string.battery_bar_threshold_percent_hint));
if (existing != null) {
input.setText(String.valueOf(existing.percent));
}
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(context)
.setTitle(R.string.battery_bar_threshold_percent_title)
.setView(input)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok, (dialogInterface, which) -> {
int percent = BatteryBarStyle.clampPercent(
parseIntInput(input, existing != null ? existing.percent : 15));
if (existing == null && containsThreshold(loadThresholds(prefs), percent, -1)) {
showMessageDialog(R.string.battery_bar_threshold_duplicate);
return;
}
consumer.accept(percent);
})
.show();
focusInput(dialog, input);
}
private void showMessageDialog(int messageRes) {
new MaterialAlertDialogBuilder(requireContext())
.setMessage(messageRes)
.setPositiveButton(android.R.string.ok, null)
.show();
}
private int parseIntInput(EditText input, int fallback) {
if (input == null) {
return fallback;
}
String text = input.getText() != null ? input.getText().toString().trim() : "";
if (text.isEmpty() || "-".equals(text)) {
return fallback;
}
try {
return Integer.parseInt(text);
} catch (NumberFormatException ignored) {
return fallback;
}
}
private void setIntInput(EditText input, int value) {
if (input != null) {
input.setText(String.valueOf(value));
}
}
private int clampInt(int value, int min, int max) {
if (value < min) {
return min;
}
if (value > max) {
return max;
}
return value;
}
private int dpToPx(Context context, int dp) {
if (context == null) {
return dp;
}
float density = context.getResources().getDisplayMetrics().density;
return (int) (dp * density + 0.5f);
}
private void applyPositionSelection(RadioButton top,
RadioButton bottom,
String position) {
if (top != null) {
top.setChecked(!"bottom".equals(position));
}
if (bottom != null) {
bottom.setChecked("bottom".equals(position));
}
}
private String positionValue(int checkedId) {
if (checkedId == R.id.battery_bar_position_bottom) {
return "bottom";
}
return "top";
}
private void applyAlignmentSelection(RadioButton ltr,
RadioButton rtl,
RadioButton centre,
String alignment) {
if (ltr != null) {
ltr.setChecked("ltr".equals(alignment));
}
if (rtl != null) {
rtl.setChecked("rtl".equals(alignment));
}
if (centre != null) {
centre.setChecked("center".equals(alignment));
}
}
private interface ColourConsumer {
void accept(String colour);
}
private interface IntConsumer {
void accept(int value);
}
private void focusInput(androidx.appcompat.app.AlertDialog dialog, EditText input) {
if (dialog == null || input == null) {
return;
}
if (dialog.getWindow() != null) {
dialog.getWindow().setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE);
}
input.post(() -> {
input.requestFocus();
if (input.getText() != null) {
input.setSelection(input.getText().length());
}
InputMethodManager imm = requireContext().getSystemService(InputMethodManager.class);
if (imm != null) {
imm.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT);
}
});
}
}
@@ -0,0 +1,162 @@
package se.ajpanton.statusbartweak.shell.ui;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ListView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import java.util.ArrayList;
import se.ajpanton.statusbartweak.R;
import se.ajpanton.statusbartweak.shell.settings.AppIconRules;
import se.ajpanton.statusbartweak.shell.settings.SbtDefaults;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
import se.ajpanton.statusbartweak.shell.ui.HiddenAppsUiSupport.HiddenAppsAdapter;
import se.ajpanton.statusbartweak.shell.ui.HiddenAppsUiSupport.RowItem;
public class BlockAppNotificationIconsFragment extends Fragment {
private final ArrayList<RowItem> rows = new ArrayList<>();
private final Handler uiHandler = new Handler(Looper.getMainLooper());
private HiddenAppsAdapter adapter;
private SharedPreferences prefs;
private PackageManager packageManager;
private Drawable fallbackIcon;
private TextView emptyView;
private ListView listView;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_block_app_notification_icons, container, false);
Context context = requireContext();
prefs = context.getSharedPreferences(SbtSettings.PREFS_NAME, Context.MODE_PRIVATE);
packageManager = context.getPackageManager();
fallbackIcon = packageManager.getDefaultActivityIcon();
emptyView = root.findViewById(R.id.hidden_apps_empty);
listView = root.findViewById(R.id.hidden_apps_list);
SwipeRefreshLayout swipeRefresh = root.findViewById(R.id.hidden_apps_swipe);
View clearSessionButton = root.findViewById(R.id.hidden_apps_clear_session);
adapter = new HiddenAppsAdapter(context, rows, (row, modeMask) -> {
if (!HiddenAppsUiSupport.toggleRowMode(row, modeMask)) {
return;
}
HiddenAppsUiSupport.persistRowState(requireContext(), prefs, row);
if (adapter != null) {
adapter.notifyDataSetChanged();
}
});
listView.setAdapter(adapter);
listView.setOnItemClickListener((parent, view, position, id) -> {
if (position < 0 || position >= rows.size()) {
return;
}
listView.clearChoices();
adapter.notifyDataSetChanged();
HiddenAppsUiSupport.flashRowTapFeedback(view);
HiddenAppsUiSupport.showModePopup(this, rows.get(position), changedRow -> {
HiddenAppsUiSupport.persistRowState(requireContext(), prefs, changedRow);
if (adapter != null) {
adapter.notifyDataSetChanged();
}
});
});
swipeRefresh.setOnRefreshListener(() -> {
reloadRowsSnapshot();
swipeRefresh.setRefreshing(false);
});
clearSessionButton.setOnClickListener(v -> {
resetSeenSessionNow();
reloadRowsSnapshot();
scheduleAutoRefreshAfterClear();
});
reloadRowsSnapshot();
applyLayoutAvailability(root);
return root;
}
@Override
public void onDestroyView() {
uiHandler.removeCallbacksAndMessages(null);
super.onDestroyView();
}
private void resetSeenSessionNow() {
if (prefs == null) {
return;
}
AppIconRules.clearSeenSession(prefs);
SbtSettings.ensureReadable(requireContext());
}
private void scheduleAutoRefreshAfterClear() {
uiHandler.removeCallbacksAndMessages(null);
uiHandler.postDelayed(this::reloadRowsSnapshot, 150);
uiHandler.postDelayed(this::reloadRowsSnapshot, 600);
uiHandler.postDelayed(this::reloadRowsSnapshot, 1400);
}
private void reloadRowsSnapshot() {
HiddenAppsUiSupport.reloadRowsSnapshot(requireContext(), prefs, packageManager, fallbackIcon, rows);
if (adapter != null) {
adapter.notifyDataSetChanged();
}
updateEmptyState();
}
private void updateEmptyState() {
if (emptyView == null) {
return;
}
emptyView.setVisibility(rows.isEmpty() ? View.VISIBLE : View.GONE);
}
private void applyLayoutAvailability(View root) {
if (root == null || prefs == null) {
return;
}
boolean enabled = prefs.getBoolean(
SbtSettings.KEY_CLOCK_ENABLED,
SbtDefaults.CLOCK_ENABLED_DEFAULT);
View content = root.findViewById(R.id.hidden_apps_content);
TextView disabledHint = root.findViewById(R.id.hidden_apps_disabled_hint);
if (content != null) {
setViewTreeEnabled(content, enabled);
content.setAlpha(enabled ? 1f : 0.45f);
}
if (disabledHint != null) {
disabledHint.setVisibility(enabled ? View.GONE : View.VISIBLE);
disabledHint.setAlpha(1f);
}
}
private void setViewTreeEnabled(View view, boolean enabled) {
if (view == null) {
return;
}
view.setEnabled(enabled);
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int i = 0; i < group.getChildCount(); i++) {
setViewTreeEnabled(group.getChildAt(i), enabled);
}
}
}
}
@@ -0,0 +1,345 @@
package se.ajpanton.statusbartweak.shell.ui;
import se.ajpanton.statusbartweak.R;
import se.ajpanton.statusbartweak.shell.settings.SbtDefaults;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Typeface;
import android.os.Bundle;
import android.text.Editable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextWatcher;
import android.text.method.LinkMovementMethod;
import android.text.style.BulletSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public final class ClockFragment extends Fragment {
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_clock, container, false);
SharedPreferences prefs = requireContext()
.getSharedPreferences(SbtSettings.PREFS_NAME, Context.MODE_PRIVATE);
CheckBox customFormatEnabled = root.findViewById(R.id.clock_custom_format_enabled);
View customFormatContainer = root.findViewById(R.id.clock_custom_format_container);
EditText customFormatInput = root.findViewById(R.id.clock_custom_format_input);
MaterialButton fontPickerButton = root.findViewById(R.id.clock_font_picker_button);
TextView customFormatHelpToggle = root.findViewById(R.id.clock_custom_format_help_toggle);
View customFormatHelpContainer = root.findViewById(R.id.clock_custom_format_help_container);
TextView customFormatHelpCommon = root.findViewById(R.id.clock_custom_format_help);
TextView customFormatHelpStyling = root.findViewById(R.id.clock_custom_format_help_styling);
TextView customFormatHelpExamples = root.findViewById(R.id.clock_custom_format_help_examples);
TextView customFormatHelpLink = root.findViewById(R.id.clock_custom_format_help_link);
boolean customFormatEnabledValue = prefs.getBoolean(
SbtSettings.KEY_CLOCK_CUSTOM_FORMAT_ENABLED,
SbtDefaults.CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT);
String customFormatValue = prefs.getString(
SbtSettings.KEY_CLOCK_CUSTOM_FORMAT,
SbtDefaults.CLOCK_CUSTOM_FORMAT_DEFAULT);
customFormatEnabled.setChecked(customFormatEnabledValue);
customFormatInput.setText(customFormatValue);
updateCustomFormatVisibility(customFormatContainer, customFormatEnabledValue);
updateCustomFormatHelpVisibility(customFormatHelpContainer, customFormatHelpToggle, false);
applyBulletText(customFormatHelpCommon);
applyBulletText(customFormatHelpStyling);
applyBulletText(customFormatHelpExamples);
if (customFormatHelpLink != null) {
customFormatHelpLink.setMovementMethod(LinkMovementMethod.getInstance());
}
customFormatEnabled.setOnCheckedChangeListener((buttonView, isChecked) -> {
updateCustomFormatVisibility(customFormatContainer, isChecked);
prefs.edit().putBoolean(SbtSettings.KEY_CLOCK_CUSTOM_FORMAT_ENABLED, isChecked).apply();
SbtSettings.ensureReadable(requireContext());
});
if (customFormatHelpToggle != null) {
customFormatHelpToggle.setOnClickListener(v -> updateCustomFormatHelpVisibility(
customFormatHelpContainer,
customFormatHelpToggle,
customFormatHelpContainer == null || customFormatHelpContainer.getVisibility() != View.VISIBLE));
}
customFormatInput.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
prefs.edit()
.putString(SbtSettings.KEY_CLOCK_CUSTOM_FORMAT, s != null ? s.toString() : "")
.apply();
SbtSettings.ensureReadable(requireContext());
}
});
if (fontPickerButton != null) {
fontPickerButton.setOnClickListener(v -> showFontPickerDialog());
}
return root;
}
private void updateCustomFormatVisibility(View customFormatContainer, boolean enabled) {
if (customFormatContainer == null) {
return;
}
customFormatContainer.setVisibility(enabled ? View.VISIBLE : View.GONE);
}
private void updateCustomFormatHelpVisibility(View helpContainer,
TextView helpToggle,
boolean visible) {
if (helpContainer != null) {
helpContainer.setVisibility(visible ? View.VISIBLE : View.GONE);
}
if (helpToggle != null) {
helpToggle.setText(visible
? R.string.clock_custom_format_help_hide
: R.string.clock_custom_format_help_show);
}
}
private void showFontPickerDialog() {
Context context = requireContext();
List<String> allFamilies = FontFamilySupport.listAvailableFamilies();
ArrayList<String> filteredFamilies = new ArrayList<>(allFamilies);
LinearLayout container = new LinearLayout(context);
container.setOrientation(LinearLayout.VERTICAL);
int padding = dpToPx(context, 20);
container.setPadding(padding, dpToPx(context, 8), padding, 0);
EditText searchInput = new EditText(context);
searchInput.setHint(R.string.clock_font_picker_hint);
searchInput.setSingleLine(true);
searchInput.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO);
container.addView(searchInput, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT));
ListView listView = new ListView(context);
listView.setDividerHeight(0);
LinearLayout.LayoutParams listParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dpToPx(context, 360));
listParams.topMargin = dpToPx(context, 12);
container.addView(listView, listParams);
FontFamilyAdapter adapter = new FontFamilyAdapter(context, filteredFamilies);
listView.setAdapter(adapter);
searchInput.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
applyFontFilter(adapter, allFamilies, s != null ? s.toString() : "");
}
});
listView.setOnItemClickListener((parent, view, position, id) -> {
if (position < 0 || position >= adapter.getCount()) {
return;
}
String family = adapter.getItem(position);
if (family == null || family.trim().isEmpty()) {
return;
}
copyFontFamily(family);
Toast.makeText(
context,
getString(R.string.clock_font_picker_copied, family),
Toast.LENGTH_SHORT).show();
});
Runnable refreshSamples = new Runnable() {
@Override
public void run() {
adapter.notifyDataSetChanged();
listView.postDelayed(this, 1000L);
}
};
AlertDialog dialog = new MaterialAlertDialogBuilder(context)
.setTitle(R.string.clock_font_picker_title)
.setView(container)
.setNegativeButton(android.R.string.cancel, null)
.show();
listView.post(refreshSamples);
dialog.setOnDismissListener(d -> listView.removeCallbacks(refreshSamples));
}
private void applyFontFilter(FontFamilyAdapter adapter, List<String> allFamilies, String query) {
if (adapter == null || allFamilies == null) {
return;
}
String needle = query != null ? query.trim().toLowerCase(Locale.ROOT) : "";
adapter.replaceAll(filterFontFamilies(allFamilies, needle));
}
private List<String> filterFontFamilies(List<String> allFamilies, String needle) {
ArrayList<String> filtered = new ArrayList<>();
for (String family : allFamilies) {
if (family == null) {
continue;
}
if (needle == null || needle.isEmpty() || family.toLowerCase(Locale.ROOT).contains(needle)) {
filtered.add(family);
}
}
return filtered;
}
private void copyFontFamily(String family) {
ClipboardManager clipboard = requireContext().getSystemService(ClipboardManager.class);
if (clipboard != null) {
clipboard.setPrimaryClip(ClipData.newPlainText("font-family", family));
}
}
private void applyBulletText(TextView view) {
if (view == null) {
return;
}
CharSequence raw = view.getText();
if (raw == null || raw.length() == 0) {
return;
}
String[] lines = raw.toString().split("\\n", -1);
SpannableStringBuilder builder = new SpannableStringBuilder();
int bulletGap = dpToPx(view.getContext(), 8);
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
boolean bulleted = line.startsWith("\u2022");
String text = bulleted ? line.substring(1).trim() : line;
int start = builder.length();
builder.append(text);
if (bulleted) {
builder.setSpan(
new BulletSpan(bulletGap),
start,
builder.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (i < lines.length - 1) {
builder.append('\n');
}
}
view.setText(builder);
}
private int dpToPx(Context context, int dp) {
if (context == null) {
return dp;
}
float density = context.getResources().getDisplayMetrics().density;
return Math.round(dp * density);
}
private static final class FontFamilyAdapter extends BaseAdapter {
private static final String SAMPLE_PATTERN = "EEEE HH:mm:ss";
private final LayoutInflater inflater;
private final ArrayList<String> families;
FontFamilyAdapter(Context context, List<String> initialFamilies) {
this.inflater = LayoutInflater.from(context);
this.families = new ArrayList<>();
replaceAll(initialFamilies);
}
void replaceAll(List<String> updatedFamilies) {
families.clear();
if (updatedFamilies != null) {
families.addAll(updatedFamilies);
}
notifyDataSetChanged();
}
@Override
public int getCount() {
return families.size();
}
@Override
public String getItem(int position) {
if (position < 0 || position >= families.size()) {
return null;
}
return families.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
private static String buildSampleText() {
return new SimpleDateFormat(SAMPLE_PATTERN, Locale.getDefault()).format(new Date());
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
View view = convertView != null
? convertView
: inflater.inflate(R.layout.item_clock_font_family, parent, false);
TextView nameView = view.findViewById(R.id.font_family_name);
TextView sampleView = view.findViewById(R.id.font_family_sample);
String family = getItem(position);
if (nameView != null) {
nameView.setText(family);
}
if (sampleView != null) {
sampleView.setText(buildSampleText());
sampleView.setTypeface(Typeface.create(family, Typeface.NORMAL));
}
return view;
}
}
}
@@ -0,0 +1,83 @@
package se.ajpanton.statusbartweak.shell.ui;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
final class FontFamilySupport {
private static final Pattern FAMILY_NAME_PATTERN = Pattern.compile(
"<(?:family|alias)\\b[^>]*\\bname\\s*=\\s*['\"]([^'\"]+)['\"]",
Pattern.CASE_INSENSITIVE);
private static final String[] FONT_CONFIG_PATHS = new String[] {
"/system/etc/fonts.xml",
"/system/etc/system_fonts.xml",
"/system_ext/etc/fonts.xml",
"/product/etc/fonts.xml",
"/vendor/etc/fonts.xml"
};
private static final List<String> FALLBACK_FAMILIES = Arrays.asList(
"sans-serif",
"sans-serif-light",
"sans-serif-medium",
"sans-serif-thin",
"sans-serif-condensed",
"sans-serif-smallcaps",
"serif",
"monospace",
"cursive",
"casual"
);
private FontFamilySupport() {
}
static List<String> listAvailableFamilies() {
Set<String> families = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
for (String path : FONT_CONFIG_PATHS) {
readFamiliesFromConfig(path, families);
}
families.addAll(FALLBACK_FAMILIES);
return new ArrayList<>(families);
}
private static void readFamiliesFromConfig(String path, Set<String> out) {
if (out == null || path == null || path.trim().isEmpty()) {
return;
}
File file = new File(path);
if (!file.isFile() || !file.canRead()) {
return;
}
StringBuilder content = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
String line;
while ((line = reader.readLine()) != null) {
content.append(line).append('\n');
}
} catch (IOException ignored) {
return;
}
Matcher matcher = FAMILY_NAME_PATTERN.matcher(content);
while (matcher.find()) {
String family = matcher.group(1);
if (family == null) {
continue;
}
String normalized = family.trim();
if (!normalized.isEmpty()) {
out.add(normalized);
}
}
}
}
@@ -0,0 +1,839 @@
package se.ajpanton.statusbartweak.shell.ui;
import se.ajpanton.statusbartweak.R;
import se.ajpanton.statusbartweak.shell.settings.AppIconRules;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.ViewParent;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.BaseAdapter;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
final class HiddenAppsUiSupport {
interface RowChangedListener {
void onChanged(RowItem row);
}
private static final int MODE_INDEX_AOD = 0;
private static final int MODE_INDEX_LOCK = 1;
private static final int MODE_INDEX_UNLOCK = 2;
private static void bindModeIcon(ImageView icon, TextView cross, Drawable drawable, boolean blocked) {
if (icon == null || cross == null) {
return;
}
if (drawable != null && drawable.getConstantState() != null) {
icon.setImageDrawable(drawable.getConstantState().newDrawable().mutate());
} else {
icon.setImageDrawable(drawable);
}
icon.setAlpha(blocked ? 0.25f : 1f);
cross.setVisibility(blocked ? View.VISIBLE : View.GONE);
}
private HiddenAppsUiSupport() {
}
static void reloadRowsSnapshot(Context context,
SharedPreferences prefs,
PackageManager packageManager,
Drawable fallbackIcon,
ArrayList<RowItem> rows) {
if (rows == null) {
return;
}
rows.clear();
if (context == null || prefs == null || packageManager == null) {
return;
}
Map<String, Integer> blocked = AppIconRules.loadBlockedModes(prefs);
Map<String, ArrayList<AppIconRules.ExceptionRule>> exceptionRules =
AppIconRules.loadExceptionRules(prefs);
Map<String, Long> seen = AppIconRules.loadSeenSession(prefs);
ArrayList<RowItem> blockedItems = new ArrayList<>();
for (Map.Entry<String, Integer> entry : blocked.entrySet()) {
String pkg = entry.getKey();
Integer maskObj = entry.getValue();
if (!AppIconRules.isValidPackageName(pkg) || maskObj == null) {
continue;
}
int mask = maskObj;
RowItem item = buildRowItem(context, packageManager, fallbackIcon, pkg);
item.blockAod = (mask & AppIconRules.MODE_AOD) != 0;
item.blockLock = (mask & AppIconRules.MODE_LOCK) != 0;
item.blockUnlock = (mask & AppIconRules.MODE_UNLOCK) != 0;
item.exceptionRules = toEditableExceptionRules(exceptionRules.get(pkg));
blockedItems.add(item);
}
blockedItems.sort((a, b) -> {
int byLabel = a.label.toLowerCase(Locale.ROOT).compareTo(b.label.toLowerCase(Locale.ROOT));
if (byLabel != 0) {
return byLabel;
}
return a.packageName.compareTo(b.packageName);
});
rows.addAll(blockedItems);
Set<String> alreadyAdded = new HashSet<>();
for (RowItem item : blockedItems) {
alreadyAdded.add(item.packageName);
}
List<AppIconRules.SeenApp> seenOldestFirst = AppIconRules.sortSeenOldestFirst(seen);
for (AppIconRules.SeenApp seenApp : seenOldestFirst) {
if (seenApp == null || !AppIconRules.isValidPackageName(seenApp.packageName)) {
continue;
}
if (alreadyAdded.contains(seenApp.packageName)) {
continue;
}
RowItem item = buildRowItem(context, packageManager, fallbackIcon, seenApp.packageName);
item.exceptionRules = toEditableExceptionRules(exceptionRules.get(seenApp.packageName));
rows.add(item);
alreadyAdded.add(seenApp.packageName);
}
}
private static RowItem buildRowItem(Context context,
PackageManager packageManager,
Drawable fallbackIcon,
String packageName) {
String label = packageName;
Drawable icon = fallbackIcon;
try {
ApplicationInfo appInfo = packageManager.getApplicationInfo(packageName, 0);
CharSequence cs = packageManager.getApplicationLabel(appInfo);
if (cs != null && cs.length() > 0) {
label = cs.toString();
}
icon = packageManager.getApplicationIcon(appInfo);
} catch (PackageManager.NameNotFoundException ignored) {
// Keep fallback label/icon.
}
if (label == null || label.trim().isEmpty()) {
label = context.getString(R.string.hidden_apps_package_unknown);
}
return new RowItem(packageName, label, icon);
}
static void showModePopup(Fragment host, RowItem row, RowChangedListener rowChangedListener) {
if (host == null || row == null) {
return;
}
Context context = host.requireContext();
View content = LayoutInflater.from(context).inflate(R.layout.dialog_hidden_app_config, null, false);
TextView popupTitle = content.findViewById(R.id.hidden_app_config_title);
popupTitle.setText(row.label);
View defaultAodCell = content.findViewById(R.id.hidden_app_default_aod_cell);
View defaultLockCell = content.findViewById(R.id.hidden_app_default_lock_cell);
View defaultUnlockCell = content.findViewById(R.id.hidden_app_default_unlock_cell);
ImageView defaultAodIcon = content.findViewById(R.id.hidden_app_default_aod_icon);
ImageView defaultLockIcon = content.findViewById(R.id.hidden_app_default_lock_icon);
ImageView defaultUnlockIcon = content.findViewById(R.id.hidden_app_default_unlock_icon);
TextView defaultAodCross = content.findViewById(R.id.hidden_app_default_aod_cross);
TextView defaultLockCross = content.findViewById(R.id.hidden_app_default_lock_cross);
TextView defaultUnlockCross = content.findViewById(R.id.hidden_app_default_unlock_cross);
RecyclerView exceptionsList = content.findViewById(R.id.hidden_app_exceptions_list);
View addException = content.findViewById(R.id.hidden_app_add_exception);
final boolean[] defaultModes = new boolean[] { row.blockAod, row.blockLock, row.blockUnlock };
bindModeIconTriple(
defaultAodIcon, defaultAodCross,
defaultLockIcon, defaultLockCross,
defaultUnlockIcon, defaultUnlockCross,
row.icon,
defaultModes[MODE_INDEX_AOD],
defaultModes[MODE_INDEX_LOCK],
defaultModes[MODE_INDEX_UNLOCK]);
final boolean[] originalModes = defaultModes.clone();
bindPopupDefaultModeToggle(defaultAodCell, defaultAodIcon, defaultAodCross,
row.icon, defaultModes, MODE_INDEX_AOD, row, rowChangedListener);
bindPopupDefaultModeToggle(defaultLockCell, defaultLockIcon, defaultLockCross,
row.icon, defaultModes, MODE_INDEX_LOCK, row, rowChangedListener);
bindPopupDefaultModeToggle(defaultUnlockCell, defaultUnlockIcon, defaultUnlockCross,
row.icon, defaultModes, MODE_INDEX_UNLOCK, row, rowChangedListener);
ArrayList<EditableExceptionRule> workingRules = copyEditableExceptionRules(row.exceptionRules);
ExceptionRulesAdapter exceptionsAdapter = new ExceptionRulesAdapter(
context,
row.icon,
workingRules,
() -> requestExceptionsRelayout(exceptionsList));
exceptionsList.setLayoutManager(new LinearLayoutManager(context));
exceptionsList.setAdapter(exceptionsAdapter);
exceptionsList.setItemAnimator(null);
exceptionsList.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS);
exceptionsList.setNestedScrollingEnabled(false);
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) {
@Override
public boolean onMove(RecyclerView recyclerView,
RecyclerView.ViewHolder viewHolder,
RecyclerView.ViewHolder target) {
int from = viewHolder.getAdapterPosition();
int to = target.getAdapterPosition();
if (from < 0 || to < 0 || from >= workingRules.size() || to >= workingRules.size()) {
return false;
}
if (from == to) {
return true;
}
Collections.swap(workingRules, from, to);
exceptionsAdapter.notifyDataSetChanged();
requestExceptionsRelayout(exceptionsList);
return true;
}
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
// Not used.
}
@Override
public boolean isLongPressDragEnabled() {
return true;
}
});
itemTouchHelper.attachToRecyclerView(exceptionsList);
exceptionsAdapter.setItemTouchHelper(itemTouchHelper);
addException.setOnClickListener(v -> {
workingRules.add(new EditableExceptionRule());
int pos = workingRules.size() - 1;
exceptionsAdapter.notifyDataSetChanged();
requestExceptionsRelayout(exceptionsList);
exceptionsList.post(() -> exceptionsList.scrollToPosition(pos));
});
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(context)
.setView(content)
.setNegativeButton(android.R.string.cancel, (d, w) -> {
if (row.blockAod != originalModes[MODE_INDEX_AOD]
|| row.blockLock != originalModes[MODE_INDEX_LOCK]
|| row.blockUnlock != originalModes[MODE_INDEX_UNLOCK]) {
row.blockAod = originalModes[MODE_INDEX_AOD];
row.blockLock = originalModes[MODE_INDEX_LOCK];
row.blockUnlock = originalModes[MODE_INDEX_UNLOCK];
notifyRowChanged(rowChangedListener, row);
}
})
.setPositiveButton(android.R.string.ok, (dlg, which) -> {
row.blockAod = defaultModes[MODE_INDEX_AOD];
row.blockLock = defaultModes[MODE_INDEX_LOCK];
row.blockUnlock = defaultModes[MODE_INDEX_UNLOCK];
row.exceptionRules = copyEditableExceptionRules(workingRules);
notifyRowChanged(rowChangedListener, row);
})
.show();
dialog.setCanceledOnTouchOutside(false);
if (dialog.getWindow() != null) {
dialog.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
dialog.getWindow().setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
| WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN);
dialog.getWindow().setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
}
compactDialogButtons(dialog, context);
requestExceptionsRelayout(exceptionsList);
}
static void persistRowState(Context context, SharedPreferences prefs, RowItem row) {
if (context == null || prefs == null || row == null) {
return;
}
Map<String, Integer> blocked = AppIconRules.loadBlockedModes(prefs);
Map<String, ArrayList<AppIconRules.ExceptionRule>> exceptionRules = AppIconRules.loadExceptionRules(prefs);
AppIconRules.setModeBlocked(blocked, row.packageName, row.blockAod, row.blockLock, row.blockUnlock);
AppIconRules.setExceptionRules(exceptionRules, row.packageName, toPersistedExceptionRules(row.exceptionRules));
AppIconRules.saveBlockedModes(prefs, blocked);
AppIconRules.saveExceptionRules(prefs, exceptionRules);
SbtSettings.ensureReadable(context);
}
static boolean toggleRowMode(RowItem row, int modeMask) {
if (row == null) {
return false;
}
if (modeMask == AppIconRules.MODE_AOD) {
row.blockAod = !row.blockAod;
return true;
}
if (modeMask == AppIconRules.MODE_LOCK) {
row.blockLock = !row.blockLock;
return true;
}
if (modeMask == AppIconRules.MODE_UNLOCK) {
row.blockUnlock = !row.blockUnlock;
return true;
}
return false;
}
static void flashRowTapFeedback(View rowView) {
if (rowView == null) {
return;
}
rowView.setPressed(true);
rowView.postDelayed(() -> {
rowView.setPressed(false);
rowView.jumpDrawablesToCurrentState();
}, 120L);
}
private static void notifyRowChanged(RowChangedListener listener, RowItem row) {
if (listener != null && row != null) {
listener.onChanged(row);
}
}
private static void bindPopupDefaultModeToggle(View target,
ImageView icon,
TextView cross,
Drawable drawable,
boolean[] defaultModes,
int modeIndex,
RowItem row,
RowChangedListener listener) {
if (defaultModes == null || modeIndex < 0 || modeIndex >= defaultModes.length) {
return;
}
if (target == null) {
return;
}
target.setOnClickListener(v -> {
defaultModes[modeIndex] = !defaultModes[modeIndex];
bindModeIcon(icon, cross, drawable, defaultModes[modeIndex]);
applyModeIndexToRow(row, modeIndex, defaultModes[modeIndex]);
notifyRowChanged(listener, row);
});
}
private static void applyModeIndexToRow(RowItem row, int modeIndex, boolean blocked) {
if (row == null) {
return;
}
if (modeIndex == MODE_INDEX_AOD) {
row.blockAod = blocked;
} else if (modeIndex == MODE_INDEX_LOCK) {
row.blockLock = blocked;
} else if (modeIndex == MODE_INDEX_UNLOCK) {
row.blockUnlock = blocked;
}
}
private static void bindModeIconTriple(ImageView aodIcon,
TextView aodCross,
ImageView lockIcon,
TextView lockCross,
ImageView unlockIcon,
TextView unlockCross,
Drawable drawable,
boolean blockAod,
boolean blockLock,
boolean blockUnlock) {
bindModeIcon(aodIcon, aodCross, drawable, blockAod);
bindModeIcon(lockIcon, lockCross, drawable, blockLock);
bindModeIcon(unlockIcon, unlockCross, drawable, blockUnlock);
}
private static void requestExceptionsRelayout(RecyclerView list) {
if (list == null) {
return;
}
list.post(() -> {
list.requestLayout();
ViewParent parent = list.getParent();
if (parent instanceof View) {
((View) parent).requestLayout();
ViewParent gp = parent.getParent();
if (gp instanceof View) {
((View) gp).requestLayout();
}
}
});
}
private static ArrayList<EditableExceptionRule> toEditableExceptionRules(List<AppIconRules.ExceptionRule> rules) {
ArrayList<EditableExceptionRule> out = new ArrayList<>();
if (rules == null) {
return out;
}
for (AppIconRules.ExceptionRule rule : rules) {
if (rule == null) {
continue;
}
EditableExceptionRule editable = new EditableExceptionRule();
editable.regex = normalizeRegex(rule.regex);
editable.blockAod = (rule.modeMask & AppIconRules.MODE_AOD) != 0;
editable.blockLock = (rule.modeMask & AppIconRules.MODE_LOCK) != 0;
editable.blockUnlock = (rule.modeMask & AppIconRules.MODE_UNLOCK) != 0;
if (editable.regex != null) {
out.add(editable);
}
}
return out;
}
private static ArrayList<EditableExceptionRule> copyEditableExceptionRules(List<EditableExceptionRule> source) {
ArrayList<EditableExceptionRule> out = new ArrayList<>();
if (source == null) {
return out;
}
for (EditableExceptionRule rule : source) {
if (rule == null) {
continue;
}
EditableExceptionRule copy = new EditableExceptionRule();
copy.regex = normalizeRegex(rule.regex);
copy.blockAod = rule.blockAod;
copy.blockLock = rule.blockLock;
copy.blockUnlock = rule.blockUnlock;
out.add(copy);
}
return out;
}
private static ArrayList<AppIconRules.ExceptionRule> toPersistedExceptionRules(List<EditableExceptionRule> source) {
ArrayList<AppIconRules.ExceptionRule> out = new ArrayList<>();
if (source == null) {
return out;
}
for (EditableExceptionRule rule : source) {
if (rule == null) {
continue;
}
String regex = normalizeRegex(rule.regex);
if (regex == null) {
continue;
}
int mask = 0;
if (rule.blockAod) {
mask |= AppIconRules.MODE_AOD;
}
if (rule.blockLock) {
mask |= AppIconRules.MODE_LOCK;
}
if (rule.blockUnlock) {
mask |= AppIconRules.MODE_UNLOCK;
}
out.add(new AppIconRules.ExceptionRule(regex, mask));
}
return out;
}
private static String normalizeRegex(String raw) {
if (raw == null) {
return null;
}
String normalized = raw.trim();
return normalized.isEmpty() ? null : normalized;
}
static final class RowItem {
final String packageName;
final String label;
final Drawable icon;
boolean blockAod;
boolean blockLock;
boolean blockUnlock;
ArrayList<EditableExceptionRule> exceptionRules = new ArrayList<>();
RowItem(String packageName, String label, Drawable icon) {
this.packageName = packageName;
this.label = label;
this.icon = icon;
}
boolean hasAnyBlocked() {
return blockAod || blockLock || blockUnlock;
}
int exceptionCount() {
int count = 0;
for (EditableExceptionRule rule : exceptionRules) {
if (rule != null && normalizeRegex(rule.regex) != null) {
count++;
}
}
return count;
}
}
static final class EditableExceptionRule {
String regex;
boolean blockAod;
boolean blockLock;
boolean blockUnlock;
}
interface RowModeToggleListener {
void onToggle(RowItem row, int modeMask);
}
private static final class RowHolder {
View root;
View iconAodCell;
View iconLockCell;
View iconUnlockCell;
ImageView iconAod;
ImageView iconLock;
ImageView iconUnlock;
TextView crossAod;
TextView crossLock;
TextView crossUnlock;
TextView label;
TextView pkg;
}
static final class HiddenAppsAdapter extends BaseAdapter {
private final Context context;
private final ArrayList<RowItem> rows;
private final RowModeToggleListener rowModeToggleListener;
HiddenAppsAdapter(Context context,
ArrayList<RowItem> rows,
RowModeToggleListener rowModeToggleListener) {
this.context = context;
this.rows = rows;
this.rowModeToggleListener = rowModeToggleListener;
}
@Override
public int getCount() {
return rows != null ? rows.size() : 0;
}
@Override
public Object getItem(int position) {
if (rows == null || position < 0 || position >= rows.size()) {
return null;
}
return rows.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
RowHolder holder;
if (convertView == null) {
convertView = LayoutInflater.from(context).inflate(R.layout.item_hidden_app, parent, false);
holder = new RowHolder();
holder.root = convertView.findViewById(R.id.hidden_item_root);
holder.iconAodCell = convertView.findViewById(R.id.icon_mode_aod_cell);
holder.iconLockCell = convertView.findViewById(R.id.icon_mode_lock_cell);
holder.iconUnlockCell = convertView.findViewById(R.id.icon_mode_unlock_cell);
holder.iconAod = convertView.findViewById(R.id.icon_mode_aod);
holder.iconLock = convertView.findViewById(R.id.icon_mode_lock);
holder.iconUnlock = convertView.findViewById(R.id.icon_mode_unlock);
holder.crossAod = convertView.findViewById(R.id.icon_mode_aod_cross);
holder.crossLock = convertView.findViewById(R.id.icon_mode_lock_cross);
holder.crossUnlock = convertView.findViewById(R.id.icon_mode_unlock_cross);
holder.label = convertView.findViewById(R.id.hidden_item_label);
holder.pkg = convertView.findViewById(R.id.hidden_item_package);
convertView.setTag(holder);
} else {
holder = (RowHolder) convertView.getTag();
}
RowItem row = rows.get(position);
holder.label.setText(row.label);
int exceptionCount = row.exceptionCount();
if (exceptionCount > 0) {
holder.pkg.setText(context.getString(
R.string.hidden_apps_package_with_exceptions, row.packageName, exceptionCount));
} else {
holder.pkg.setText(row.packageName);
}
bindModeIconTriple(
holder.iconAod, holder.crossAod,
holder.iconLock, holder.crossLock,
holder.iconUnlock, holder.crossUnlock,
row.icon,
row.blockAod, row.blockLock, row.blockUnlock);
int bg = row.hasAnyBlocked()
? ContextCompat.getColor(context, R.color.sbt_hidden_item_active_bg)
: Color.TRANSPARENT;
holder.root.setBackgroundColor(bg);
holder.root.setPressed(false);
bindModeToggle(holder.iconAodCell, row, AppIconRules.MODE_AOD);
bindModeToggle(holder.iconLockCell, row, AppIconRules.MODE_LOCK);
bindModeToggle(holder.iconUnlockCell, row, AppIconRules.MODE_UNLOCK);
return convertView;
}
private void bindModeToggle(View target, RowItem row, int modeMask) {
if (target == null) {
return;
}
target.setOnClickListener(v -> {
if (rowModeToggleListener != null) {
rowModeToggleListener.onToggle(row, modeMask);
}
});
}
}
private static final class ExceptionRuleHolder extends RecyclerView.ViewHolder {
final View dragRoot;
final TextView title;
final TextView deleteButton;
final EditText regexField;
final View aodCell;
final View lockCell;
final View unlockCell;
final ImageView aodIcon;
final ImageView lockIcon;
final ImageView unlockIcon;
final TextView aodCross;
final TextView lockCross;
final TextView unlockCross;
TextWatcher watcher;
ExceptionRuleHolder(View itemView) {
super(itemView);
dragRoot = itemView.findViewById(R.id.exception_rule_drag_root);
title = itemView.findViewById(R.id.exception_rule_title);
deleteButton = itemView.findViewById(R.id.exception_rule_delete);
regexField = itemView.findViewById(R.id.exception_rule_regex);
aodCell = itemView.findViewById(R.id.exception_rule_aod_cell);
lockCell = itemView.findViewById(R.id.exception_rule_lock_cell);
unlockCell = itemView.findViewById(R.id.exception_rule_unlock_cell);
aodIcon = itemView.findViewById(R.id.exception_rule_aod_icon);
lockIcon = itemView.findViewById(R.id.exception_rule_lock_icon);
unlockIcon = itemView.findViewById(R.id.exception_rule_unlock_icon);
aodCross = itemView.findViewById(R.id.exception_rule_aod_cross);
lockCross = itemView.findViewById(R.id.exception_rule_lock_cross);
unlockCross = itemView.findViewById(R.id.exception_rule_unlock_cross);
}
}
private static final class ExceptionRulesAdapter
extends RecyclerView.Adapter<ExceptionRuleHolder> {
private final Context context;
private final Drawable appIcon;
private final ArrayList<EditableExceptionRule> rules;
private ItemTouchHelper itemTouchHelper;
private final Runnable onRulesCountChanged;
ExceptionRulesAdapter(Context context,
Drawable appIcon,
ArrayList<EditableExceptionRule> rules,
Runnable onRulesCountChanged) {
this.context = context;
this.appIcon = appIcon;
this.rules = rules;
this.onRulesCountChanged = onRulesCountChanged;
}
void setItemTouchHelper(ItemTouchHelper itemTouchHelper) {
this.itemTouchHelper = itemTouchHelper;
}
@Override
public ExceptionRuleHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = LayoutInflater.from(context)
.inflate(R.layout.item_hidden_app_exception_rule, parent, false);
RecyclerView.LayoutParams lp = new RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
view.setLayoutParams(lp);
return new ExceptionRuleHolder(view);
}
@Override
public void onBindViewHolder(ExceptionRuleHolder holder, int position) {
EditableExceptionRule rule = rules.get(position);
holder.title.setText(context.getString(R.string.hidden_apps_exception_label, position + 1));
if (holder.watcher != null) {
holder.regexField.removeTextChangedListener(holder.watcher);
}
holder.regexField.setText(rule.regex != null ? rule.regex : "");
holder.watcher = new SimpleTextWatcher() {
@Override
public void afterTextChanged(Editable s) {
int pos = holder.getAdapterPosition();
if (pos == RecyclerView.NO_POSITION || pos >= rules.size()) {
return;
}
rules.get(pos).regex = s != null ? s.toString() : null;
}
};
holder.regexField.addTextChangedListener(holder.watcher);
holder.regexField.setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus) {
showKeyboard(holder.regexField);
}
});
holder.regexField.setOnClickListener(v -> showKeyboard(holder.regexField));
holder.regexField.setOnTouchListener((v, event) -> {
v.getParent().requestDisallowInterceptTouchEvent(true);
return false;
});
bindModeIconTriple(
holder.aodIcon, holder.aodCross,
holder.lockIcon, holder.lockCross,
holder.unlockIcon, holder.unlockCross,
appIcon,
rule.blockAod, rule.blockLock, rule.blockUnlock);
bindExceptionModeToggle(holder, holder.aodCell, AppIconRules.MODE_AOD);
bindExceptionModeToggle(holder, holder.lockCell, AppIconRules.MODE_LOCK);
bindExceptionModeToggle(holder, holder.unlockCell, AppIconRules.MODE_UNLOCK);
holder.deleteButton.setOnClickListener(v -> {
int pos = holder.getAdapterPosition();
if (pos == RecyclerView.NO_POSITION || pos >= rules.size()) {
return;
}
rules.remove(pos);
notifyDataSetChanged();
if (onRulesCountChanged != null) {
onRulesCountChanged.run();
}
});
holder.dragRoot.setOnLongClickListener(v -> {
if (itemTouchHelper != null) {
itemTouchHelper.startDrag(holder);
return true;
}
return false;
});
}
@Override
public int getItemCount() {
return rules != null ? rules.size() : 0;
}
private void bindExceptionModeToggle(ExceptionRuleHolder holder, View target, int modeMask) {
if (target == null) {
return;
}
target.setOnClickListener(v -> {
int pos = holder.getAdapterPosition();
if (pos == RecyclerView.NO_POSITION || pos >= rules.size()) {
return;
}
EditableExceptionRule r = rules.get(pos);
if (modeMask == AppIconRules.MODE_AOD) {
r.blockAod = !r.blockAod;
} else if (modeMask == AppIconRules.MODE_LOCK) {
r.blockLock = !r.blockLock;
} else if (modeMask == AppIconRules.MODE_UNLOCK) {
r.blockUnlock = !r.blockUnlock;
}
bindModeIconTriple(
holder.aodIcon, holder.aodCross,
holder.lockIcon, holder.lockCross,
holder.unlockIcon, holder.unlockCross,
appIcon,
r.blockAod, r.blockLock, r.blockUnlock);
});
}
}
private abstract static class SimpleTextWatcher implements TextWatcher {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
}
private static void showKeyboard(EditText field) {
if (field == null) {
return;
}
field.post(() -> {
field.requestFocus();
InputMethodManager imm = (InputMethodManager) field.getContext()
.getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) {
field.requestFocusFromTouch();
boolean shown = imm.showSoftInput(field, InputMethodManager.SHOW_IMPLICIT);
if (!shown) {
imm.showSoftInput(field, InputMethodManager.SHOW_FORCED);
}
}
});
}
private static void compactDialogButtons(androidx.appcompat.app.AlertDialog dialog, Context context) {
if (dialog == null || context == null) {
return;
}
int small = Math.round(2f * context.getResources().getDisplayMetrics().density);
Button pos = dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE);
Button neg = dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE);
if (pos != null) {
pos.setMinHeight(0);
pos.setMinimumHeight(0);
pos.setPadding(pos.getPaddingLeft(), small, pos.getPaddingRight(), small);
}
if (neg != null) {
neg.setMinHeight(0);
neg.setMinimumHeight(0);
neg.setPadding(neg.getPaddingLeft(), small, neg.getPaddingRight(), small);
}
if (pos != null) {
ViewParent p = pos.getParent();
if (p instanceof ViewGroup) {
ViewGroup panel = (ViewGroup) p;
panel.setPadding(panel.getPaddingLeft(), small, panel.getPaddingRight(), small);
ViewParent gp = panel.getParent();
if (gp instanceof ViewGroup) {
ViewGroup gpGroup = (ViewGroup) gp;
gpGroup.setPadding(gpGroup.getPaddingLeft(), small, gpGroup.getPaddingRight(), small);
}
}
}
}
}
@@ -0,0 +1,459 @@
package se.ajpanton.statusbartweak.shell.ui;
import android.Manifest;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.switchmaterial.SwitchMaterial;
import java.util.ArrayList;
import java.util.Map;
import se.ajpanton.statusbartweak.R;
import se.ajpanton.statusbartweak.shell.debug.DebugNotifications;
import se.ajpanton.statusbartweak.shell.settings.ClockCameraTypeSupport;
import se.ajpanton.statusbartweak.shell.settings.SbtDefaults;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
public class IconsDebugFragment extends Fragment {
private static final int REQUEST_POST_NOTIFICATIONS = 1001;
private TextView countView;
private int currentCount;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_icons_debug, container, false);
countView = root.findViewById(R.id.debug_count_value);
MaterialButton minus10 = root.findViewById(R.id.btn_minus_10);
MaterialButton minus1 = root.findViewById(R.id.btn_minus_1);
MaterialButton plus1 = root.findViewById(R.id.btn_plus_1);
MaterialButton plus10 = root.findViewById(R.id.btn_plus_10);
MaterialButton reset = root.findViewById(R.id.btn_reset);
SwitchMaterial writeLogsSwitch = root.findViewById(R.id.debug_write_logs_switch);
SwitchMaterial visualizeNotificationContainerSwitch =
root.findViewById(R.id.debug_visualize_notification_container_switch);
SwitchMaterial visualizeStatusContainerSwitch =
root.findViewById(R.id.debug_visualize_status_container_switch);
SwitchMaterial visualizeClockContainerSwitch =
root.findViewById(R.id.debug_visualize_clock_container_switch);
SwitchMaterial visualizeChipContainerSwitch =
root.findViewById(R.id.debug_visualize_chip_container_switch);
SwitchMaterial visualizeCameraCutoutSwitch =
root.findViewById(R.id.debug_visualize_camera_cutout_switch);
MaterialButton restartSystemUiButton = root.findViewById(R.id.debug_restart_systemui_button);
SwitchMaterial cycleIconsSwitch = root.findViewById(R.id.debug_cycle_icons_switch);
TextView currentCameraIdentified = root.findViewById(R.id.debug_camera_current_identified);
LinearLayout currentCameraOverrideRow = root.findViewById(R.id.debug_camera_current_override_row);
TextView currentCameraOverride = root.findViewById(R.id.debug_camera_current_override);
MaterialButton currentCameraAction = root.findViewById(R.id.debug_camera_current_action);
View otherCameraSection = root.findViewById(R.id.debug_camera_other_section);
LinearLayout otherCameraList = root.findViewById(R.id.debug_camera_other_list);
currentCount = DebugNotifications.getDesiredCount(requireContext());
updateCountLabel();
bindCycleSwitch(cycleIconsSwitch);
minus10.setOnClickListener(v -> adjustCount(-10));
minus1.setOnClickListener(v -> adjustCount(-1));
plus1.setOnClickListener(v -> adjustCount(1));
plus10.setOnClickListener(v -> adjustCount(10));
reset.setOnClickListener(v -> setCount(0));
SharedPreferences prefs = requireContext()
.getSharedPreferences(SbtSettings.PREFS_NAME, Context.MODE_PRIVATE);
bindWriteLogsSwitch(prefs, writeLogsSwitch);
bindBooleanSwitch(
prefs,
visualizeNotificationContainerSwitch,
SbtSettings.KEY_DEBUG_VISUALIZE_NOTIFICATION_CONTAINER,
SbtDefaults.DEBUG_VISUALIZE_NOTIFICATION_CONTAINER_DEFAULT);
bindBooleanSwitch(
prefs,
visualizeStatusContainerSwitch,
SbtSettings.KEY_DEBUG_VISUALIZE_STATUS_CONTAINER,
SbtDefaults.DEBUG_VISUALIZE_STATUS_CONTAINER_DEFAULT);
bindBooleanSwitch(
prefs,
visualizeClockContainerSwitch,
SbtSettings.KEY_DEBUG_VISUALIZE_CLOCK_CONTAINER,
SbtDefaults.DEBUG_VISUALIZE_CLOCK_CONTAINER_DEFAULT);
bindBooleanSwitch(
prefs,
visualizeChipContainerSwitch,
SbtSettings.KEY_DEBUG_VISUALIZE_CHIP_CONTAINER,
SbtDefaults.DEBUG_VISUALIZE_CHIP_CONTAINER_DEFAULT);
bindBooleanSwitch(
prefs,
visualizeCameraCutoutSwitch,
SbtSettings.KEY_DEBUG_VISUALIZE_CAMERA_CUTOUT,
SbtDefaults.DEBUG_VISUALIZE_CAMERA_CUTOUT_DEFAULT);
bindRestartSystemUiButton(restartSystemUiButton);
updateVisualizerSwitchAvailability(
prefs,
visualizeNotificationContainerSwitch,
visualizeStatusContainerSwitch,
visualizeClockContainerSwitch,
visualizeChipContainerSwitch);
Runnable refreshCameraUi = () -> updateCameraUi(
prefs,
currentCameraIdentified,
currentCameraOverrideRow,
currentCameraOverride,
currentCameraAction,
otherCameraSection,
otherCameraList);
SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPrefs, key) -> {
if (SbtSettings.KEY_DEBUG_CURRENT_CAMERA_ID.equals(key)
|| SbtSettings.KEY_DEBUG_CURRENT_CAMERA_AUTO_TYPE.equals(key)
|| SbtSettings.KEY_DEBUG_CURRENT_CAMERA_EFFECTIVE_TYPE.equals(key)
|| SbtSettings.KEY_CLOCK_CAMERA_TYPE_OVERRIDES.equals(key)) {
refreshCameraUi.run();
}
if (SbtSettings.KEY_CLOCK_ENABLED.equals(key)) {
updateVisualizerSwitchAvailability(
prefs,
visualizeNotificationContainerSwitch,
visualizeStatusContainerSwitch,
visualizeClockContainerSwitch,
visualizeChipContainerSwitch);
}
};
prefs.registerOnSharedPreferenceChangeListener(listener);
root.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
@Override
public void onViewAttachedToWindow(View v) {
}
@Override
public void onViewDetachedFromWindow(View v) {
prefs.unregisterOnSharedPreferenceChangeListener(listener);
root.removeOnAttachStateChangeListener(this);
}
});
refreshCameraUi.run();
return root;
}
private void bindWriteLogsSwitch(SharedPreferences prefs, SwitchMaterial writeLogsSwitch) {
if (prefs == null || writeLogsSwitch == null) {
return;
}
writeLogsSwitch.setChecked(prefs.getBoolean(
SbtSettings.KEY_DEBUG_WRITE_LOGS,
SbtDefaults.DEBUG_WRITE_LOGS_DEFAULT));
writeLogsSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
prefs.edit().putBoolean(SbtSettings.KEY_DEBUG_WRITE_LOGS, isChecked).commit();
SbtSettings.ensureReadable(requireContext());
});
}
private void bindBooleanSwitch(SharedPreferences prefs,
SwitchMaterial switchView,
String key,
boolean defaultValue) {
if (prefs == null || switchView == null || key == null) {
return;
}
switchView.setChecked(prefs.getBoolean(key, defaultValue));
switchView.setOnCheckedChangeListener((buttonView, isChecked) -> {
prefs.edit().putBoolean(key, isChecked).commit();
SbtSettings.ensureReadable(requireContext());
});
}
private void bindRestartSystemUiButton(MaterialButton restartButton) {
if (restartButton == null) {
return;
}
restartButton.setOnClickListener(v -> restartSystemUi());
}
private void updateVisualizerSwitchAvailability(SharedPreferences prefs,
SwitchMaterial notificationSwitch,
SwitchMaterial statusSwitch,
SwitchMaterial clockSwitch,
SwitchMaterial chipSwitch) {
boolean layoutEnabled = prefs != null && prefs.getBoolean(
SbtSettings.KEY_CLOCK_ENABLED,
SbtDefaults.CLOCK_ENABLED_DEFAULT);
if (notificationSwitch != null) {
notificationSwitch.setEnabled(layoutEnabled);
}
if (statusSwitch != null) {
statusSwitch.setEnabled(layoutEnabled);
}
if (clockSwitch != null) {
clockSwitch.setEnabled(layoutEnabled);
}
if (chipSwitch != null) {
chipSwitch.setEnabled(layoutEnabled);
}
}
private void restartSystemUi() {
Context appContext = requireContext().getApplicationContext();
Toast.makeText(appContext, R.string.debug_restart_systemui_started, Toast.LENGTH_SHORT).show();
new Thread(() -> {
boolean success = false;
try {
Process process = new ProcessBuilder(
"su",
"-c",
"killall com.android.systemui || pkill -f com.android.systemui")
.redirectErrorStream(true)
.start();
success = process.waitFor() == 0;
} catch (Throwable ignored) {
}
if (!success && isAdded()) {
requireActivity().runOnUiThread(() -> Toast.makeText(
requireContext(),
R.string.debug_restart_systemui_failed,
Toast.LENGTH_LONG).show());
}
}, "StatusBarTweak:SystemUIRestart").start();
}
private void bindCycleSwitch(SwitchMaterial cycleSwitch) {
if (cycleSwitch == null) {
return;
}
Context context = requireContext();
cycleSwitch.setChecked(DebugNotifications.isCycleEnabled(context));
cycleSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
if (isChecked) {
ensureNotificationPermission();
}
DebugNotifications.setCycleEnabled(context, isChecked);
});
}
private void adjustCount(int delta) {
setCount(currentCount + delta);
}
private void setCount(int value) {
int clamped = DebugNotifications.clampCount(value);
if (clamped == currentCount) {
return;
}
currentCount = clamped;
updateCountLabel();
if (currentCount > 0) {
ensureNotificationPermission();
}
DebugNotifications.setDesiredCount(requireContext(), currentCount);
}
private void updateCountLabel() {
if (countView != null) {
countView.setText(String.valueOf(currentCount));
}
}
private void ensureNotificationPermission() {
Context context = requireContext();
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED) {
return;
}
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQUEST_POST_NOTIFICATIONS);
Toast.makeText(
context,
R.string.debug_notification_permission_required,
Toast.LENGTH_SHORT).show();
}
@Override
public void onRequestPermissionsResult(int requestCode,
@NonNull String[] permissions,
@NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_POST_NOTIFICATIONS
&& grantResults.length > 0
&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
DebugNotifications.applyPending(requireContext());
}
}
private void updateCameraUi(SharedPreferences prefs,
TextView identifiedView,
LinearLayout overrideRow,
TextView overrideView,
MaterialButton actionButton,
View otherSection,
LinearLayout otherList) {
if (identifiedView == null) {
return;
}
String cameraId = prefs != null
? prefs.getString(SbtSettings.KEY_DEBUG_CURRENT_CAMERA_ID, "")
: "";
String autoType = prefs != null
? prefs.getString(SbtSettings.KEY_DEBUG_CURRENT_CAMERA_AUTO_TYPE, "")
: "";
String effectiveTypeFromReport = prefs != null
? prefs.getString(SbtSettings.KEY_DEBUG_CURRENT_CAMERA_EFFECTIVE_TYPE, "")
: "";
Map<String, String> overrides = ClockCameraTypeSupport.parseOverrides(
prefs != null
? prefs.getStringSet(SbtSettings.KEY_CLOCK_CAMERA_TYPE_OVERRIDES, java.util.Collections.emptySet())
: java.util.Collections.emptySet());
if (TextUtils.isEmpty(cameraId)) {
identifiedView.setText(getString(R.string.debug_camera_identified_as,
getString(R.string.debug_camera_unknown)));
if (overrideRow != null) {
overrideRow.setVisibility(View.GONE);
}
} else {
String currentOverride = overrides.get(cameraId);
String currentAutoType = ClockCameraTypeSupport.normalizeType(autoType);
String currentEffectiveType = !TextUtils.isEmpty(currentOverride)
? currentOverride
: (TextUtils.isEmpty(effectiveTypeFromReport)
? currentAutoType
: ClockCameraTypeSupport.normalizeType(effectiveTypeFromReport));
identifiedView.setText(getString(
R.string.debug_camera_identified_as,
readableType(currentAutoType)));
if (overrideRow != null && overrideView != null && actionButton != null) {
overrideRow.setVisibility(View.VISIBLE);
if (TextUtils.isEmpty(currentOverride)) {
overrideView.setText("");
actionButton.setText(R.string.debug_camera_action_wrong);
actionButton.setOnClickListener(v -> saveCameraOverride(
prefs,
cameraId,
ClockCameraTypeSupport.oppositeType(currentAutoType)));
} else {
overrideView.setText(getString(
R.string.debug_camera_overridden_as,
readableType(currentEffectiveType)));
actionButton.setText(R.string.debug_camera_action_undo);
actionButton.setOnClickListener(v -> removeCameraOverride(prefs, cameraId));
}
}
}
if (otherSection == null || otherList == null) {
return;
}
otherList.removeAllViews();
ArrayList<String> keys = new ArrayList<>(overrides.keySet());
java.util.Collections.sort(keys);
int shown = 0;
for (String key : keys) {
if (!TextUtils.isEmpty(cameraId) && TextUtils.equals(key, cameraId)) {
continue;
}
otherList.addView(createOtherCameraRow(key, overrides.get(key), prefs, shown > 0));
shown++;
}
otherSection.setVisibility(shown > 0 ? View.VISIBLE : View.GONE);
}
private View createOtherCameraRow(String cameraId,
String overrideType,
SharedPreferences prefs,
boolean addTopMargin) {
Context context = requireContext();
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(android.view.Gravity.CENTER_VERTICAL);
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
if (addTopMargin) {
rowParams.topMargin = dpToPx(context, 8);
}
row.setLayoutParams(rowParams);
TextView label = new TextView(context);
LinearLayout.LayoutParams labelParams = new LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT);
labelParams.weight = 1f;
label.setLayoutParams(labelParams);
label.setText(getString(
R.string.debug_camera_other_item,
cameraId,
readableType(overrideType)));
otherListTextAppearance(label);
row.addView(label);
MaterialButton undoButton = new MaterialButton(
context,
null,
com.google.android.material.R.attr.materialButtonOutlinedStyle);
undoButton.setMinWidth(0);
undoButton.setText(R.string.debug_camera_action_undo);
undoButton.setOnClickListener(v -> removeCameraOverride(prefs, cameraId));
row.addView(undoButton);
return row;
}
private void otherListTextAppearance(TextView view) {
if (view == null) {
return;
}
view.setTextAppearance(com.google.android.material.R.style.TextAppearance_MaterialComponents_Body2);
}
private void saveCameraOverride(SharedPreferences prefs, String cameraId, String type) {
if (prefs == null || TextUtils.isEmpty(cameraId)) {
return;
}
Map<String, String> overrides = new java.util.HashMap<>(
ClockCameraTypeSupport.parseOverrides(
prefs.getStringSet(SbtSettings.KEY_CLOCK_CAMERA_TYPE_OVERRIDES, java.util.Collections.emptySet())));
overrides.put(cameraId, ClockCameraTypeSupport.normalizeType(type));
ClockCameraTypeSupport.saveOverrides(prefs, SbtSettings.KEY_CLOCK_CAMERA_TYPE_OVERRIDES, overrides);
SbtSettings.ensureReadable(requireContext());
}
private void removeCameraOverride(SharedPreferences prefs, String cameraId) {
if (prefs == null || TextUtils.isEmpty(cameraId)) {
return;
}
Map<String, String> overrides = new java.util.HashMap<>(
ClockCameraTypeSupport.parseOverrides(
prefs.getStringSet(SbtSettings.KEY_CLOCK_CAMERA_TYPE_OVERRIDES, java.util.Collections.emptySet())));
overrides.remove(cameraId);
ClockCameraTypeSupport.saveOverrides(prefs, SbtSettings.KEY_CLOCK_CAMERA_TYPE_OVERRIDES, overrides);
SbtSettings.ensureReadable(requireContext());
}
private String readableType(String type) {
return ClockCameraTypeSupport.formatType(type);
}
private int dpToPx(Context context, int dp) {
if (context == null) {
return dp;
}
float density = context.getResources().getDisplayMetrics().density;
return (int) (dp * density + 0.5f);
}
}
@@ -0,0 +1,827 @@
package se.ajpanton.statusbartweak.shell.ui;
import se.ajpanton.statusbartweak.R;
import se.ajpanton.statusbartweak.shell.settings.SbtDefaults;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.text.Editable;
import android.text.InputFilter;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.RadioGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.switchmaterial.SwitchMaterial;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class LayoutFragment extends Fragment {
private static final int PADDING_MIN = -999;
private static final int PADDING_MAX = 999;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_layout, container, false);
SharedPreferences prefs = requireContext()
.getSharedPreferences(SbtSettings.PREFS_NAME, Context.MODE_PRIVATE);
SwitchMaterial enabledSwitch = root.findViewById(R.id.clock_enabled_switch);
enabledSwitch.setChecked(prefs.getBoolean(
SbtSettings.KEY_CLOCK_ENABLED,
SbtDefaults.CLOCK_ENABLED_DEFAULT));
enabledSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
prefs.edit().putBoolean(SbtSettings.KEY_CLOCK_ENABLED, isChecked).apply();
SbtSettings.ensureReadable(requireContext());
});
setupPositionSection(root,
prefs,
R.id.clock_position_group,
R.id.clock_position_middle_options_container,
R.id.clock_middle_auto_nearest,
R.id.clock_middle_side_prompt,
SbtSettings.KEY_CLOCK_POSITION,
SbtDefaults.CLOCK_POSITION_DEFAULT,
SbtSettings.KEY_CLOCK_MIDDLE_AUTO_NEAREST_SIDE,
SbtSettings.KEY_CLOCK_MIDDLE_CUTOUT_SIDE);
setupPositionSection(root,
prefs,
R.id.notif_position_group,
R.id.notif_position_middle_options_container,
R.id.notif_middle_auto_nearest,
R.id.notif_middle_side_prompt,
SbtSettings.KEY_LAYOUT_NOTIF_POSITION,
SbtDefaults.LAYOUT_NOTIF_POSITION_DEFAULT,
SbtSettings.KEY_LAYOUT_NOTIF_MIDDLE_AUTO_NEAREST_SIDE,
SbtSettings.KEY_LAYOUT_NOTIF_MIDDLE_SIDE);
setupPositionSection(root,
prefs,
R.id.chip_position_group,
R.id.chip_position_middle_options_container,
R.id.chip_middle_auto_nearest,
R.id.chip_middle_side_prompt,
SbtSettings.KEY_LAYOUT_CHIP_POSITION,
SbtDefaults.LAYOUT_CHIP_POSITION_DEFAULT,
SbtSettings.KEY_LAYOUT_CHIP_MIDDLE_AUTO_NEAREST_SIDE,
SbtSettings.KEY_LAYOUT_CHIP_MIDDLE_SIDE);
setupPositionSection(root,
prefs,
R.id.status_position_group,
R.id.status_position_middle_options_container,
R.id.status_middle_auto_nearest,
R.id.status_middle_side_prompt,
SbtSettings.KEY_LAYOUT_STATUS_POSITION,
SbtDefaults.LAYOUT_STATUS_POSITION_DEFAULT,
SbtSettings.KEY_LAYOUT_STATUS_MIDDLE_AUTO_NEAREST_SIDE,
SbtSettings.KEY_LAYOUT_STATUS_MIDDLE_SIDE);
bindStepper(prefs,
SbtSettings.KEY_LAYOUT_PADDING_CUTOUT_PX,
root.findViewById(R.id.layout_cutout_gap_input),
root.findViewById(R.id.layout_cutout_gap_minus),
root.findViewById(R.id.layout_cutout_gap_plus),
SbtDefaults.LAYOUT_PADDING_CUTOUT_PX_DEFAULT);
bindStepper(prefs,
SbtSettings.KEY_LAYOUT_PADDING_LEFT_PX,
root.findViewById(R.id.layout_left_padding_input),
root.findViewById(R.id.layout_left_padding_minus),
root.findViewById(R.id.layout_left_padding_plus),
SbtDefaults.LAYOUT_PADDING_LEFT_PX_DEFAULT);
bindStepper(prefs,
SbtSettings.KEY_LAYOUT_PADDING_RIGHT_PX,
root.findViewById(R.id.layout_right_padding_input),
root.findViewById(R.id.layout_right_padding_minus),
root.findViewById(R.id.layout_right_padding_plus),
SbtDefaults.LAYOUT_PADDING_RIGHT_PX_DEFAULT);
bindStepper(prefs,
SbtSettings.KEY_LAYOUT_ITEM_PADDING_PX,
root.findViewById(R.id.layout_item_padding_input),
root.findViewById(R.id.layout_item_padding_minus),
root.findViewById(R.id.layout_item_padding_plus),
SbtDefaults.LAYOUT_ITEM_PADDING_PX_DEFAULT,
SbtDefaults.LAYOUT_ITEM_PADDING_PX_MIN,
SbtDefaults.LAYOUT_ITEM_PADDING_PX_MAX);
bindStepper(prefs,
SbtSettings.KEY_CLOCK_VERTICAL_OFFSET_PX,
root.findViewById(R.id.clock_vertical_offset_input),
root.findViewById(R.id.clock_vertical_offset_minus_fast),
root.findViewById(R.id.clock_vertical_offset_minus),
root.findViewById(R.id.clock_vertical_offset_plus),
root.findViewById(R.id.clock_vertical_offset_plus_fast),
root.findViewById(R.id.clock_vertical_offset_reset),
SbtDefaults.CLOCK_VERTICAL_OFFSET_PX_DEFAULT);
bindStepper(prefs,
SbtSettings.KEY_LAYOUT_NOTIF_VERTICAL_OFFSET_PX,
root.findViewById(R.id.notif_vertical_offset_input),
root.findViewById(R.id.notif_vertical_offset_minus_fast),
root.findViewById(R.id.notif_vertical_offset_minus),
root.findViewById(R.id.notif_vertical_offset_plus),
root.findViewById(R.id.notif_vertical_offset_plus_fast),
root.findViewById(R.id.notif_vertical_offset_reset),
SbtDefaults.LAYOUT_NOTIF_VERTICAL_OFFSET_PX_DEFAULT);
bindStepper(prefs,
SbtSettings.KEY_LAYOUT_CHIP_VERTICAL_OFFSET_PX,
root.findViewById(R.id.chip_vertical_offset_input),
root.findViewById(R.id.chip_vertical_offset_minus_fast),
root.findViewById(R.id.chip_vertical_offset_minus),
root.findViewById(R.id.chip_vertical_offset_plus),
root.findViewById(R.id.chip_vertical_offset_plus_fast),
root.findViewById(R.id.chip_vertical_offset_reset),
SbtDefaults.LAYOUT_CHIP_VERTICAL_OFFSET_PX_DEFAULT);
bindStepper(prefs,
SbtSettings.KEY_LAYOUT_STATUS_VERTICAL_OFFSET_PX,
root.findViewById(R.id.status_vertical_offset_input),
root.findViewById(R.id.status_vertical_offset_minus_fast),
root.findViewById(R.id.status_vertical_offset_minus),
root.findViewById(R.id.status_vertical_offset_plus),
root.findViewById(R.id.status_vertical_offset_plus_fast),
root.findViewById(R.id.status_vertical_offset_reset),
SbtDefaults.LAYOUT_STATUS_VERTICAL_OFFSET_PX_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.layout_left_padding_add_stock),
SbtSettings.KEY_LAYOUT_PADDING_LEFT_ADD_STOCK,
SbtDefaults.LAYOUT_PADDING_LEFT_ADD_STOCK_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.layout_right_padding_add_stock),
SbtSettings.KEY_LAYOUT_PADDING_RIGHT_ADD_STOCK,
SbtDefaults.LAYOUT_PADDING_RIGHT_ADD_STOCK_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.layout_ignore_under_display_cameras),
SbtSettings.KEY_LAYOUT_IGNORE_UNDER_DISPLAY_CAMERAS,
SbtDefaults.LAYOUT_IGNORE_UNDER_DISPLAY_CAMERAS_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.clock_position_mode_lockscreen),
SbtSettings.KEY_CLOCK_ENABLED_LOCKSCREEN,
SbtDefaults.CLOCK_ENABLED_LOCKSCREEN_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.clock_position_mode_unlocked),
SbtSettings.KEY_CLOCK_ENABLED_UNLOCKED,
SbtDefaults.CLOCK_ENABLED_UNLOCKED_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.notif_position_mode_aod),
SbtSettings.KEY_LAYOUT_NOTIF_ENABLED_AOD,
SbtDefaults.LAYOUT_NOTIF_ENABLED_AOD_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.notif_position_mode_lockscreen),
SbtSettings.KEY_LAYOUT_NOTIF_ENABLED_LOCKSCREEN,
SbtDefaults.LAYOUT_NOTIF_ENABLED_LOCKSCREEN_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.notif_position_mode_unlocked),
SbtSettings.KEY_LAYOUT_NOTIF_ENABLED_UNLOCKED,
SbtDefaults.LAYOUT_NOTIF_ENABLED_UNLOCKED_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.chip_position_mode_lockscreen),
SbtSettings.KEY_LAYOUT_CHIP_ENABLED_LOCKSCREEN,
SbtDefaults.LAYOUT_CHIP_ENABLED_LOCKSCREEN_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.chip_position_mode_unlocked),
SbtSettings.KEY_LAYOUT_CHIP_ENABLED_UNLOCKED,
SbtDefaults.LAYOUT_CHIP_ENABLED_UNLOCKED_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.status_position_mode_aod),
SbtSettings.KEY_LAYOUT_STATUS_ENABLED_AOD,
SbtDefaults.LAYOUT_STATUS_ENABLED_AOD_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.status_position_mode_lockscreen),
SbtSettings.KEY_LAYOUT_STATUS_ENABLED_LOCKSCREEN,
SbtDefaults.LAYOUT_STATUS_ENABLED_LOCKSCREEN_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.status_position_mode_unlocked),
SbtSettings.KEY_LAYOUT_STATUS_ENABLED_UNLOCKED,
SbtDefaults.LAYOUT_STATUS_ENABLED_UNLOCKED_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.notif_show_dot_if_truncated),
SbtSettings.KEY_LAYOUT_NOTIF_SHOW_DOT_IF_TRUNCATED,
SbtDefaults.LAYOUT_NOTIF_SHOW_DOT_IF_TRUNCATED_DEFAULT);
bindCheckBoxPref(prefs, root.findViewById(R.id.status_show_dot_if_truncated),
SbtSettings.KEY_LAYOUT_STATUS_SHOW_DOT_IF_TRUNCATED,
SbtDefaults.LAYOUT_STATUS_SHOW_DOT_IF_TRUNCATED_DEFAULT);
bindSortOrderGroup(prefs, root.findViewById(R.id.layout_sort_icons_group));
setupOrderingList(root, prefs);
return root;
}
private void setupPositionSection(View root,
SharedPreferences prefs,
int groupId,
int middleOptionsId,
int autoNearestId,
int promptId,
@Nullable String positionKey,
@Nullable String positionDefault,
@Nullable String autoNearestKey,
@Nullable String middleSideKey) {
RadioGroup group = root.findViewById(groupId);
View middleOptions = root.findViewById(middleOptionsId);
CheckBox autoNearest = root.findViewById(autoNearestId);
TextView prompt = root.findViewById(promptId);
if (group == null || middleOptions == null || autoNearest == null || prompt == null) {
return;
}
if (positionKey != null && positionDefault != null) {
group.check(positionIdForValue(groupId, prefs.getString(positionKey, positionDefault)));
}
if (autoNearestKey != null) {
autoNearest.setChecked(prefs.getBoolean(autoNearestKey, true));
}
updateMiddleOptions(middleOptions, autoNearest, prompt, group.getCheckedRadioButtonId());
group.setOnCheckedChangeListener((radioGroup, id) -> {
updateMiddleOptions(middleOptions, autoNearest, prompt, id);
if (positionKey != null) {
prefs.edit().putString(positionKey, positionValueForId(id)).apply();
SbtSettings.ensureReadable(requireContext());
}
});
autoNearest.setOnCheckedChangeListener((buttonView, isChecked) -> {
updateMiddlePrompt(prompt, isChecked);
if (autoNearestKey != null) {
prefs.edit().putBoolean(autoNearestKey, isChecked).apply();
SbtSettings.ensureReadable(requireContext());
}
});
if (middleSideKey != null) {
RadioGroup sideGroup = resolveMiddleSideGroup(root, groupId);
if (sideGroup != null) {
sideGroup.check(sideIdForValue(sideGroup, prefs.getString(middleSideKey, "left")));
sideGroup.setOnCheckedChangeListener((g, checkedId) -> {
prefs.edit().putString(middleSideKey, checkedId == resolveRightSideId(g) ? "right" : "left").apply();
SbtSettings.ensureReadable(requireContext());
});
}
}
}
private void updateMiddleOptions(View middleOptions,
CheckBox autoNearest,
TextView prompt,
int checkedId) {
if (middleOptions == null || autoNearest == null || prompt == null) {
return;
}
if (middleOptions.getVisibility() != View.VISIBLE) {
middleOptions.setVisibility(View.VISIBLE);
}
boolean enabled = isMiddleSelection(checkedId);
setViewTreeEnabled(middleOptions, enabled);
middleOptions.setAlpha(enabled ? 1f : 0.45f);
updateMiddlePrompt(prompt, autoNearest.isChecked());
}
private boolean isMiddleSelection(int checkedId) {
return checkedId == R.id.clock_position_middle
|| checkedId == R.id.notif_position_middle
|| checkedId == R.id.chip_position_middle
|| checkedId == R.id.status_position_middle;
}
private void updateMiddlePrompt(TextView prompt, boolean autoNearest) {
if (prompt == null) {
return;
}
prompt.setText(autoNearest
? R.string.layout_middle_side_prompt_centred
: R.string.layout_middle_side_prompt_attach_only);
}
private void bindSortOrderGroup(SharedPreferences prefs, @Nullable RadioGroup group) {
if (prefs == null || group == null) {
return;
}
String current = prefs.getString(
SbtSettings.KEY_LAYOUT_NOTIF_SORT_ORDER,
SbtDefaults.LAYOUT_NOTIF_SORT_ORDER_DEFAULT);
group.check(sortOrderIdForValue(current));
group.setOnCheckedChangeListener((radioGroup, checkedId) -> {
prefs.edit().putString(
SbtSettings.KEY_LAYOUT_NOTIF_SORT_ORDER,
sortOrderValueForId(checkedId)).apply();
SbtSettings.ensureReadable(requireContext());
});
}
private void bindStepper(SharedPreferences prefs,
@Nullable String key,
@Nullable EditText input,
@Nullable MaterialButton minusButton,
@Nullable MaterialButton plusButton,
int initialValue) {
bindStepper(prefs, key, input, null, minusButton, plusButton, null, null, initialValue);
}
private void bindStepper(SharedPreferences prefs,
@Nullable String key,
@Nullable EditText input,
@Nullable MaterialButton minusButton,
@Nullable MaterialButton plusButton,
int initialValue,
int minValue,
int maxValue) {
bindStepper(
prefs,
key,
input,
null,
minusButton,
plusButton,
null,
null,
initialValue,
minValue,
maxValue);
}
private void bindStepper(SharedPreferences prefs,
@Nullable String key,
@Nullable EditText input,
@Nullable MaterialButton minusFastButton,
@Nullable MaterialButton minusButton,
@Nullable MaterialButton plusButton,
@Nullable MaterialButton plusFastButton,
@Nullable MaterialButton resetButton,
int initialValue) {
bindStepper(
prefs,
key,
input,
minusFastButton,
minusButton,
plusButton,
plusFastButton,
resetButton,
initialValue,
PADDING_MIN,
PADDING_MAX);
}
private void bindStepper(SharedPreferences prefs,
@Nullable String key,
@Nullable EditText input,
@Nullable MaterialButton minusFastButton,
@Nullable MaterialButton minusButton,
@Nullable MaterialButton plusButton,
@Nullable MaterialButton plusFastButton,
@Nullable MaterialButton resetButton,
int initialValue,
int minValue,
int maxValue) {
if (input == null) {
return;
}
int startValue = clampValue(key != null ? prefs.getInt(key, initialValue) : initialValue, minValue, maxValue);
input.setFilters(new InputFilter[]{new InputFilter.LengthFilter(4)});
setNumericText(input, startValue);
final boolean[] updating = new boolean[]{false};
bindStepperButton(prefs, key, input, updating, minusFastButton, -5, minValue, maxValue);
bindStepperButton(prefs, key, input, updating, minusButton, -1, minValue, maxValue);
bindStepperButton(prefs, key, input, updating, plusButton, 1, minValue, maxValue);
bindStepperButton(prefs, key, input, updating, plusFastButton, 5, minValue, maxValue);
if (resetButton != null) {
resetButton.setOnClickListener(v -> {
updating[0] = true;
int next = clampValue(initialValue, minValue, maxValue);
setNumericText(input, next);
updating[0] = false;
persistIntPref(prefs, key, next);
});
}
input.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
if (updating[0]) {
return;
}
String text = s != null ? s.toString().trim() : "";
if (text.isEmpty() || "-".equals(text)) {
persistIntPref(prefs, key, clampValue(0, minValue, maxValue));
return;
}
try {
int parsed = clampValue(Integer.parseInt(text), minValue, maxValue);
if (parsed != Integer.parseInt(text)) {
updating[0] = true;
setNumericText(input, parsed);
updating[0] = false;
}
persistIntPref(prefs, key, parsed);
} catch (NumberFormatException ignored) {
}
}
});
input.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE) {
int next = clampValue(parseInt(input, 0), minValue, maxValue);
updating[0] = true;
setNumericText(input, next);
updating[0] = false;
persistIntPref(prefs, key, next);
}
return false;
});
input.setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus) {
return;
}
int next = clampValue(parseInt(input, 0), minValue, maxValue);
updating[0] = true;
setNumericText(input, next);
updating[0] = false;
persistIntPref(prefs, key, next);
});
}
private void bindStepperButton(SharedPreferences prefs,
@Nullable String key,
EditText input,
boolean[] updating,
@Nullable MaterialButton button,
int delta,
int minValue,
int maxValue) {
if (button == null) {
return;
}
button.setOnClickListener(v -> {
int next = clampValue(parseInt(input, 0) + delta, minValue, maxValue);
updating[0] = true;
setNumericText(input, next);
updating[0] = false;
persistIntPref(prefs, key, next);
});
}
private void bindCheckBoxPref(SharedPreferences prefs,
@Nullable CheckBox checkBox,
String key,
boolean defaultValue) {
if (checkBox == null) {
return;
}
checkBox.setChecked(prefs.getBoolean(key, defaultValue));
checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> {
prefs.edit().putBoolean(key, isChecked).apply();
SbtSettings.ensureReadable(requireContext());
});
}
private void persistIntPref(SharedPreferences prefs, @Nullable String key, int value) {
if (key == null) {
return;
}
prefs.edit().putInt(key, value).apply();
SbtSettings.ensureReadable(requireContext());
}
private void setViewTreeEnabled(View view, boolean enabled) {
if (view == null) {
return;
}
view.setEnabled(enabled);
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int i = 0; i < group.getChildCount(); i++) {
setViewTreeEnabled(group.getChildAt(i), enabled);
}
}
}
private int positionIdForValue(int groupId, @Nullable String value) {
String normalized = value != null ? value : "left";
if (groupId == R.id.clock_position_group) {
if ("right".equals(normalized)) {
return R.id.clock_position_right;
}
if ("middle".equals(normalized)) {
return R.id.clock_position_middle;
}
return R.id.clock_position_left;
}
if (groupId == R.id.notif_position_group) {
if ("right".equals(normalized)) {
return R.id.notif_position_right;
}
if ("middle".equals(normalized)) {
return R.id.notif_position_middle;
}
return R.id.notif_position_left;
}
if (groupId == R.id.chip_position_group) {
if ("right".equals(normalized)) {
return R.id.chip_position_right;
}
if ("middle".equals(normalized)) {
return R.id.chip_position_middle;
}
return R.id.chip_position_left;
}
if ("right".equals(normalized)) {
return R.id.status_position_right;
}
if ("middle".equals(normalized)) {
return R.id.status_position_middle;
}
return R.id.status_position_left;
}
private String positionValueForId(int checkedId) {
if (checkedId == R.id.clock_position_middle
|| checkedId == R.id.notif_position_middle
|| checkedId == R.id.chip_position_middle
|| checkedId == R.id.status_position_middle) {
return "middle";
}
if (checkedId == R.id.clock_position_right
|| checkedId == R.id.notif_position_right
|| checkedId == R.id.chip_position_right
|| checkedId == R.id.status_position_right) {
return "right";
}
return "left";
}
@Nullable
private RadioGroup resolveMiddleSideGroup(View root, int groupId) {
if (groupId == R.id.clock_position_group) {
return root.findViewById(R.id.clock_middle_side_group);
}
if (groupId == R.id.notif_position_group) {
return root.findViewById(R.id.notif_middle_side_group);
}
if (groupId == R.id.chip_position_group) {
return root.findViewById(R.id.chip_middle_side_group);
}
return root.findViewById(R.id.status_middle_side_group);
}
private int sideIdForValue(RadioGroup group, @Nullable String value) {
return "right".equals(value) ? resolveRightSideId(group) : resolveLeftSideId(group);
}
private int resolveLeftSideId(RadioGroup group) {
int id = group.getChildCount() > 0 ? group.getChildAt(0).getId() : View.NO_ID;
return id != View.NO_ID ? id : 0;
}
private int resolveRightSideId(RadioGroup group) {
int id = group.getChildCount() > 1 ? group.getChildAt(1).getId() : View.NO_ID;
return id != View.NO_ID ? id : resolveLeftSideId(group);
}
private int parseInt(EditText input, int fallback) {
if (input == null) {
return fallback;
}
String text = input.getText() != null ? input.getText().toString().trim() : "";
if (text.isEmpty() || "-".equals(text)) {
return fallback;
}
try {
return Integer.parseInt(text);
} catch (NumberFormatException ignored) {
return fallback;
}
}
private int clampPadding(int value) {
return clampValue(value, PADDING_MIN, PADDING_MAX);
}
private int clampValue(int value, int minValue, int maxValue) {
if (value < minValue) {
return minValue;
}
if (value > maxValue) {
return maxValue;
}
return value;
}
private void setNumericText(EditText input, int value) {
if (input == null) {
return;
}
String text = String.valueOf(value);
if (!text.contentEquals(input.getText())) {
input.setText(text);
input.setSelection(text.length());
}
}
private int sortOrderIdForValue(@Nullable String value) {
if ("stock".equals(value)) {
return R.id.layout_sort_icons_stock;
}
if ("reversed".equals(value)) {
return R.id.layout_sort_icons_reversed;
}
return R.id.layout_sort_icons_automatic;
}
private String sortOrderValueForId(int checkedId) {
if (checkedId == R.id.layout_sort_icons_stock) {
return "stock";
}
if (checkedId == R.id.layout_sort_icons_reversed) {
return "reversed";
}
return "automatic";
}
private void setupOrderingList(View root, SharedPreferences prefs) {
RecyclerView list = root.findViewById(R.id.layout_ordering_list);
if (list == null) {
return;
}
ArrayList<OrderingItem> items = parseOrderingItems(
prefs.getString(SbtSettings.KEY_LAYOUT_ITEM_ORDER, SbtDefaults.LAYOUT_ITEM_ORDER_DEFAULT));
list.setLayoutManager(new LinearLayoutManager(requireContext()));
OrderingAdapter adapter = new OrderingAdapter(items, order -> {
prefs.edit().putString(SbtSettings.KEY_LAYOUT_ITEM_ORDER, order).apply();
SbtSettings.ensureReadable(requireContext());
});
list.setAdapter(adapter);
final ItemTouchHelper[] itemTouchHelperHolder = new ItemTouchHelper[1];
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) {
@Override
public boolean onMove(@NonNull RecyclerView recyclerView,
@NonNull RecyclerView.ViewHolder viewHolder,
@NonNull RecyclerView.ViewHolder target) {
int from = viewHolder.getAdapterPosition();
int to = target.getAdapterPosition();
if (from == RecyclerView.NO_POSITION || to == RecyclerView.NO_POSITION) {
return false;
}
adapter.moveItem(from, to);
return true;
}
@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
}
@Override
public boolean isLongPressDragEnabled() {
return false;
}
});
itemTouchHelper.attachToRecyclerView(list);
itemTouchHelperHolder[0] = itemTouchHelper;
adapter.setDragStarter(holder -> {
ItemTouchHelper helper = itemTouchHelperHolder[0];
if (helper != null) {
helper.startDrag(holder);
}
});
}
private ArrayList<OrderingItem> parseOrderingItems(@Nullable String encoded) {
if (encoded == null || encoded.isEmpty() || "clock,notifications,status".equals(encoded)) {
encoded = SbtDefaults.LAYOUT_ITEM_ORDER_DEFAULT;
}
ArrayList<OrderingItem> items = new ArrayList<>();
for (String token : encoded.split(",")) {
addOrderingItemIfValid(items, token != null ? token.trim() : "");
}
addOrderingItemIfValid(items, "clock");
addOrderingItemIfValid(items, "chip");
addOrderingItemIfValid(items, "status");
addOrderingItemIfValid(items, "notifications");
return items;
}
private void addOrderingItemIfValid(ArrayList<OrderingItem> items, String key) {
if (key == null || key.isEmpty()) {
return;
}
for (OrderingItem item : items) {
if (item.key.equals(key)) {
return;
}
}
int labelRes;
if ("clock".equals(key)) {
labelRes = R.string.layout_ordering_clock;
} else if ("chip".equals(key)) {
labelRes = R.string.layout_ordering_chip;
} else if ("notifications".equals(key)) {
labelRes = R.string.layout_ordering_notif_icons;
} else if ("status".equals(key)) {
labelRes = R.string.layout_ordering_status_icons;
} else {
return;
}
items.add(new OrderingItem(key, getString(labelRes)));
}
private record OrderingItem(String key, String label) {
}
private static final class OrderingAdapter extends RecyclerView.Adapter<OrderingViewHolder> {
interface DragStarter {
void startDrag(OrderingViewHolder holder);
}
interface OrderChangedListener {
void onOrderChanged(String encodedOrder);
}
private final List<OrderingItem> items;
private final OrderChangedListener orderChangedListener;
private DragStarter dragStarter;
OrderingAdapter(List<OrderingItem> items, OrderChangedListener orderChangedListener) {
this.items = items;
this.orderChangedListener = orderChangedListener;
}
void setDragStarter(DragStarter dragStarter) {
this.dragStarter = dragStarter;
}
void moveItem(int from, int to) {
if (from == to || from < 0 || to < 0 || from >= items.size() || to >= items.size()) {
return;
}
Collections.swap(items, from, to);
notifyItemMoved(from, to);
if (orderChangedListener != null) {
orderChangedListener.onOrderChanged(encodeOrder());
}
}
private String encodeOrder() {
StringBuilder builder = new StringBuilder();
for (OrderingItem item : items) {
if (builder.length() > 0) {
builder.append(',');
}
builder.append(item.key);
}
return builder.toString();
}
@NonNull
@Override
public OrderingViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_layout_ordering, parent, false);
return new OrderingViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull OrderingViewHolder holder, int position) {
holder.label.setText(items.get(position).label);
holder.handle.setOnTouchListener((v, event) -> {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN && dragStarter != null) {
dragStarter.startDrag(holder);
return true;
}
return false;
});
}
@Override
public int getItemCount() {
return items.size();
}
}
private static final class OrderingViewHolder extends RecyclerView.ViewHolder {
final TextView label;
final TextView handle;
OrderingViewHolder(@NonNull View itemView) {
super(itemView);
label = itemView.findViewById(R.id.layout_ordering_item_label);
handle = itemView.findViewById(R.id.layout_ordering_item_handle);
}
}
}
@@ -0,0 +1,177 @@
package se.ajpanton.statusbartweak.shell.ui;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBarDrawerToggle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.android.material.navigation.NavigationView;
import se.ajpanton.statusbartweak.R;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
public class MainActivity extends AppCompatActivity implements NavigationView.OnNavigationItemSelectedListener {
private static final String EXTRA_OPEN_NAV_ITEM = "open_nav_item";
private DrawerLayout drawerLayout;
private NavigationView navigationView;
public static Intent newOpenDebugIntent(Context context) {
Intent intent = new Intent(context, MainActivity.class);
intent.putExtra(EXTRA_OPEN_NAV_ITEM, R.id.nav_icons_debug);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
return intent;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
SbtSettings.restoreCredentialProtectedPreferences(this);
setContentView(R.layout.activity_main);
MaterialToolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
drawerLayout = findViewById(R.id.drawer_layout);
navigationView = findViewById(R.id.nav_view);
navigationView.setNavigationItemSelectedListener(this);
navigationView.getMenu().setGroupCheckable(0, true, true);
ActionBarDrawerToggle toggle = new ActionBarDrawerToggle(
this,
drawerLayout,
toolbar,
R.string.nav_open,
R.string.nav_close
);
drawerLayout.addDrawerListener(toggle);
toggle.syncState();
if (savedInstanceState == null) {
int initialItemId = resolveInitialItemId(getIntent());
navigateTo(initialItemId);
consumeOpenNavItemExtra(getIntent());
} else {
handleOpenNavIntent(getIntent());
}
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleOpenNavIntent(intent);
}
@Override
public boolean onNavigationItemSelected(@NonNull android.view.MenuItem item) {
navigateTo(item.getItemId());
drawerLayout.closeDrawer(GravityCompat.START);
return true;
}
private void navigateTo(int itemId) {
syncDrawerSelection(itemId);
Fragment fragment;
if (itemId == R.id.nav_system_icons) {
fragment = new SystemIconsFragment();
} else if (itemId == R.id.nav_layout) {
fragment = new LayoutFragment();
} else if (itemId == R.id.nav_battery_bar) {
fragment = new BatteryBarFragment();
} else if (itemId == R.id.nav_clock) {
fragment = new ClockFragment();
} else if (itemId == R.id.nav_notification_icons) {
fragment = new NotificationIconsFragment();
} else if (itemId == R.id.nav_block_app_notification_icons) {
fragment = new BlockAppNotificationIconsFragment();
} else if (itemId == R.id.nav_status_chips) {
fragment = new StatusChipsFragment();
} else if (itemId == R.id.nav_misc) {
fragment = new MiscFragment();
} else {
fragment = new IconsDebugFragment();
}
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.content_frame, fragment)
.commit();
setTitle(resolveTitle(itemId));
}
private int resolveTitle(int itemId) {
if (itemId == R.id.nav_system_icons) {
return R.string.nav_system_icons;
} else if (itemId == R.id.nav_layout) {
return R.string.nav_layout;
} else if (itemId == R.id.nav_notification_icons) {
return R.string.nav_notification_icons;
} else if (itemId == R.id.nav_block_app_notification_icons) {
return R.string.hidden_apps_page_title;
} else if (itemId == R.id.nav_clock) {
return R.string.nav_clock;
} else if (itemId == R.id.nav_battery_bar) {
return R.string.nav_battery_bar;
} else if (itemId == R.id.nav_status_chips) {
return R.string.nav_status_chips;
} else if (itemId == R.id.nav_misc) {
return R.string.nav_misc;
}
return R.string.nav_icons_debug;
}
private void handleOpenNavIntent(Intent intent) {
int itemId = resolveOpenItemId(intent);
if (itemId == -1) {
return;
}
navigateTo(itemId);
consumeOpenNavItemExtra(intent);
}
private void syncDrawerSelection(int itemId) {
if (navigationView == null) {
return;
}
Menu menu = navigationView.getMenu();
if (menu == null) {
return;
}
for (int i = 0; i < menu.size(); i++) {
MenuItem item = menu.getItem(i);
if (item == null) {
continue;
}
item.setCheckable(true);
item.setChecked(item.getItemId() == itemId);
}
navigationView.invalidate();
}
private int resolveInitialItemId(Intent intent) {
int requested = resolveOpenItemId(intent);
return requested != -1 ? requested : R.id.nav_layout;
}
private int resolveOpenItemId(Intent intent) {
if (intent == null || !intent.hasExtra(EXTRA_OPEN_NAV_ITEM)) {
return -1;
}
return intent.getIntExtra(EXTRA_OPEN_NAV_ITEM, -1);
}
private void consumeOpenNavItemExtra(Intent intent) {
if (intent != null) {
intent.removeExtra(EXTRA_OPEN_NAV_ITEM);
}
}
}
@@ -0,0 +1,58 @@
package se.ajpanton.statusbartweak.shell.ui;
import se.ajpanton.statusbartweak.R;
import se.ajpanton.statusbartweak.shell.settings.SbtDefaults;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.google.android.material.switchmaterial.SwitchMaterial;
public class MiscFragment extends Fragment {
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_misc, container, false);
SharedPreferences prefs = requireContext()
.getSharedPreferences(SbtSettings.PREFS_NAME, Context.MODE_PRIVATE);
SbtSettings.ensureReadable(requireContext());
bindSwitch(root, prefs,
R.id.misc_aod_keep_visible_during_call_switch,
SbtSettings.KEY_AOD_KEEP_VISIBLE_DURING_CALL,
SbtDefaults.AOD_KEEP_VISIBLE_DURING_CALL_DEFAULT);
bindSwitch(root, prefs,
R.id.misc_battery_lockscreen_unplugged_percent_switch,
SbtSettings.KEY_BATTERY_LOCKSCREEN_UNPLUGGED_PERCENT,
SbtDefaults.BATTERY_LOCKSCREEN_UNPLUGGED_PERCENT_DEFAULT);
bindSwitch(root, prefs,
R.id.misc_battery_aod_unplugged_percent_switch,
SbtSettings.KEY_BATTERY_AOD_UNPLUGGED_PERCENT,
SbtDefaults.BATTERY_AOD_UNPLUGGED_PERCENT_DEFAULT);
return root;
}
private void bindSwitch(View root,
SharedPreferences prefs,
int switchId,
String key,
boolean defaultValue) {
SwitchMaterial toggle = root.findViewById(switchId);
toggle.setChecked(prefs.getBoolean(key, defaultValue));
toggle.setOnCheckedChangeListener((buttonView, isChecked) -> {
prefs.edit().putBoolean(key, isChecked).apply();
SbtSettings.ensureReadable(requireContext());
});
}
}
@@ -0,0 +1,489 @@
package se.ajpanton.statusbartweak.shell.ui;
import se.ajpanton.statusbartweak.R;
import se.ajpanton.statusbartweak.shell.settings.SbtDefaults;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.text.Editable;
import android.text.InputFilter;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import java.util.Arrays;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.slider.Slider;
import com.google.android.material.switchmaterial.SwitchMaterial;
public class NotificationIconsFragment extends Fragment {
private static final int INF_VALUE = Integer.MAX_VALUE;
private static final String INF_LABEL = "\u221e";
private static final int SLIDER_STEPS = 200;
private static final int INF_GAP_STEPS = 20;
private static final double SLIDER_EXPONENT = 2.0;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_notification_icons, container, false);
SharedPreferences prefs = requireContext()
.getSharedPreferences(SbtSettings.PREFS_NAME, Context.MODE_PRIVATE);
SbtSettings.ensureReadable(requireContext());
bindNumberControl(root, prefs,
R.id.unlocked_max_icons_slider,
R.id.unlocked_max_icons_input,
R.id.unlocked_max_icons_minus,
R.id.unlocked_max_icons_plus,
SbtSettings.KEY_UNLOCKED_MAX_ICONS_PER_ROW,
SbtDefaults.UNLOCKED_MAX_ICONS_PER_ROW_DEFAULT,
SbtDefaults.UNLOCKED_MAX_ICONS_PER_ROW_MIN,
SbtDefaults.UNLOCKED_MAX_ICONS_PER_ROW_MAX,
true);
bindNumberControl(root, prefs,
R.id.icons_aod_max_icons_slider,
R.id.icons_aod_max_icons_input,
R.id.icons_aod_max_icons_minus,
R.id.icons_aod_max_icons_plus,
SbtSettings.KEY_ICONS_AOD_MAX_ICONS_PER_ROW,
SbtDefaults.ICONS_AOD_MAX_ICONS_PER_ROW_DEFAULT,
SbtDefaults.ICONS_AOD_MAX_ICONS_PER_ROW_MIN,
SbtDefaults.ICONS_AOD_MAX_ICONS_PER_ROW_MAX,
true);
bindNumberControl(root, prefs,
R.id.icons_aod_max_rows_slider,
R.id.icons_aod_max_rows_input,
R.id.icons_aod_max_rows_minus,
R.id.icons_aod_max_rows_plus,
SbtSettings.KEY_ICONS_AOD_MAX_ROWS,
SbtDefaults.ICONS_AOD_MAX_ROWS_DEFAULT,
SbtDefaults.ICONS_AOD_MAX_ROWS_MIN,
SbtDefaults.ICONS_AOD_MAX_ROWS_MAX,
true);
bindSwitch(root, prefs,
R.id.icons_aod_even_switch,
SbtSettings.KEY_ICONS_AOD_EVEN_DISTRIBUTION,
SbtDefaults.ICONS_AOD_EVEN_DISTRIBUTION_DEFAULT);
bindSwitch(root, prefs,
R.id.icons_aod_cutout_switch,
SbtSettings.KEY_ICONS_AOD_CUTOUT_LIMIT,
SbtDefaults.ICONS_AOD_CUTOUT_LIMIT_DEFAULT);
bindNumberControl(root, prefs,
R.id.icons_lock_max_icons_slider,
R.id.icons_lock_max_icons_input,
R.id.icons_lock_max_icons_minus,
R.id.icons_lock_max_icons_plus,
SbtSettings.KEY_ICONS_LOCK_MAX_ICONS_PER_ROW,
SbtDefaults.ICONS_LOCK_MAX_ICONS_PER_ROW_DEFAULT,
SbtDefaults.ICONS_LOCK_MAX_ICONS_PER_ROW_MIN,
SbtDefaults.ICONS_LOCK_MAX_ICONS_PER_ROW_MAX,
true);
bindNumberControl(root, prefs,
R.id.icons_lock_max_rows_slider,
R.id.icons_lock_max_rows_input,
R.id.icons_lock_max_rows_minus,
R.id.icons_lock_max_rows_plus,
SbtSettings.KEY_ICONS_LOCK_MAX_ROWS,
SbtDefaults.ICONS_LOCK_MAX_ROWS_DEFAULT,
SbtDefaults.ICONS_LOCK_MAX_ROWS_MIN,
SbtDefaults.ICONS_LOCK_MAX_ROWS_MAX,
true);
bindSwitch(root, prefs,
R.id.icons_lock_even_switch,
SbtSettings.KEY_ICONS_LOCK_EVEN_DISTRIBUTION,
SbtDefaults.ICONS_LOCK_EVEN_DISTRIBUTION_DEFAULT);
bindSwitch(root, prefs,
R.id.icons_lock_cutout_switch,
SbtSettings.KEY_ICONS_LOCK_CUTOUT_LIMIT,
SbtDefaults.ICONS_LOCK_CUTOUT_LIMIT_DEFAULT);
bindNumberControl(root, prefs,
R.id.cards_aod_max_icons_slider,
R.id.cards_aod_max_icons_input,
R.id.cards_aod_max_icons_minus,
R.id.cards_aod_max_icons_plus,
SbtSettings.KEY_CARDS_AOD_MAX_ICONS_PER_ROW,
SbtDefaults.CARDS_AOD_MAX_ICONS_PER_ROW_DEFAULT,
SbtDefaults.CARDS_AOD_MAX_ICONS_PER_ROW_MIN,
SbtDefaults.CARDS_AOD_MAX_ICONS_PER_ROW_MAX,
true);
bindNumberControl(root, prefs,
R.id.cards_aod_max_rows_slider,
R.id.cards_aod_max_rows_input,
R.id.cards_aod_max_rows_minus,
R.id.cards_aod_max_rows_plus,
SbtSettings.KEY_CARDS_AOD_MAX_ROWS,
SbtDefaults.CARDS_AOD_MAX_ROWS_DEFAULT,
SbtDefaults.CARDS_AOD_MAX_ROWS_MIN,
SbtDefaults.CARDS_AOD_MAX_ROWS_MAX,
true);
bindSwitch(root, prefs,
R.id.cards_aod_even_switch,
SbtSettings.KEY_CARDS_AOD_EVEN_DISTRIBUTION,
SbtDefaults.CARDS_AOD_EVEN_DISTRIBUTION_DEFAULT);
bindSwitch(root, prefs,
R.id.cards_aod_limit_width_switch,
SbtSettings.KEY_CARDS_AOD_LIMIT_TO_WIDTH,
SbtDefaults.CARDS_AOD_LIMIT_TO_WIDTH_DEFAULT);
bindNumberControl(root, prefs,
R.id.cards_lock_max_icons_slider,
R.id.cards_lock_max_icons_input,
R.id.cards_lock_max_icons_minus,
R.id.cards_lock_max_icons_plus,
SbtSettings.KEY_CARDS_LOCK_MAX_ICONS_PER_ROW,
SbtDefaults.CARDS_LOCK_MAX_ICONS_PER_ROW_DEFAULT,
SbtDefaults.CARDS_LOCK_MAX_ICONS_PER_ROW_MIN,
SbtDefaults.CARDS_LOCK_MAX_ICONS_PER_ROW_MAX,
true);
bindNumberControl(root, prefs,
R.id.cards_lock_max_rows_slider,
R.id.cards_lock_max_rows_input,
R.id.cards_lock_max_rows_minus,
R.id.cards_lock_max_rows_plus,
SbtSettings.KEY_CARDS_LOCK_MAX_ROWS,
SbtDefaults.CARDS_LOCK_MAX_ROWS_DEFAULT,
SbtDefaults.CARDS_LOCK_MAX_ROWS_MIN,
SbtDefaults.CARDS_LOCK_MAX_ROWS_MAX,
true);
bindSwitch(root, prefs,
R.id.cards_lock_even_switch,
SbtSettings.KEY_CARDS_LOCK_EVEN_DISTRIBUTION,
SbtDefaults.CARDS_LOCK_EVEN_DISTRIBUTION_DEFAULT);
bindSwitch(root, prefs,
R.id.cards_lock_limit_width_switch,
SbtSettings.KEY_CARDS_LOCK_LIMIT_TO_WIDTH,
SbtDefaults.CARDS_LOCK_LIMIT_TO_WIDTH_DEFAULT);
applyLayoutAvailability(root, prefs);
return root;
}
private void applyLayoutAvailability(View root, SharedPreferences prefs) {
if (root == null || prefs == null) {
return;
}
boolean layoutEnabled = prefs.getBoolean(
SbtSettings.KEY_CLOCK_ENABLED,
SbtDefaults.CLOCK_ENABLED_DEFAULT);
View unlockedSection = root.findViewById(R.id.notification_icons_unlocked_section);
View layoutSections = root.findViewById(R.id.notification_icons_layout_sections);
View layoutRequiredHint = root.findViewById(R.id.notification_icons_layout_required_hint);
View unlockedObsoleteHint = root.findViewById(R.id.notification_icons_unlocked_obsolete_hint);
if (unlockedSection != null) {
setViewTreeEnabled(unlockedSection, !layoutEnabled);
unlockedSection.setAlpha(layoutEnabled ? 0.45f : 1f);
}
if (layoutSections != null) {
setViewTreeEnabled(layoutSections, layoutEnabled);
layoutSections.setAlpha(layoutEnabled ? 1f : 0.45f);
}
if (layoutRequiredHint != null) {
layoutRequiredHint.setVisibility(layoutEnabled ? View.GONE : View.VISIBLE);
}
if (unlockedObsoleteHint != null) {
unlockedObsoleteHint.setVisibility(layoutEnabled ? View.VISIBLE : View.GONE);
}
}
private void setViewTreeEnabled(View view, boolean enabled) {
if (view == null) {
return;
}
view.setEnabled(enabled);
if (view instanceof ViewGroup group) {
for (int i = 0; i < group.getChildCount(); i++) {
setViewTreeEnabled(group.getChildAt(i), enabled);
}
}
}
private void bindNumberControl(View root,
SharedPreferences prefs,
int sliderId,
int inputId,
int minusId,
int plusId,
String key,
int defaultValue,
int min,
int max,
boolean allowInf) {
Slider slider = root.findViewById(sliderId);
EditText input = root.findViewById(inputId);
MaterialButton minus = root.findViewById(minusId);
MaterialButton plus = root.findViewById(plusId);
int[] positions = buildPositions(min, max);
int maxPos = SLIDER_STEPS - INF_GAP_STEPS;
slider.setValueFrom(0);
slider.setValueTo(SLIDER_STEPS);
slider.setStepSize(1f);
slider.setTickVisible(false);
slider.setLabelFormatter(value -> labelForSliderValue(Math.round(value), min, max, allowInf, positions));
final boolean[] updating = new boolean[]{false};
int current = clampInt(prefs.getInt(key, defaultValue), min, Integer.MAX_VALUE);
applyValueToUi(slider, input, min, max, allowInf, positions, current, updating);
input.setFilters(new InputFilter[]{new InputFilter.LengthFilter(6)});
slider.addOnChangeListener((s, v, fromUser) -> {
if (updating[0]) {
return;
}
int pos = Math.round(v);
int snapped = snapPosition(pos, positions, allowInf, maxPos);
if (snapped != pos) {
updating[0] = true;
slider.setValue(snapped);
updating[0] = false;
}
int mapped = positionToValue(snapped, min, max, allowInf, positions, maxPos);
int nextValue = mapped;
updating[0] = true;
if (mapped == INF_VALUE) {
input.setText(INF_LABEL);
} else {
input.setText(String.valueOf(mapped));
}
updating[0] = false;
if (fromUser) {
prefs.edit().putInt(key, nextValue).apply();
SbtSettings.ensureReadable(requireContext());
}
});
minus.setOnClickListener(v -> {
int parsed = parseInput(input, current);
int next;
if (parsed == INF_VALUE) {
next = max;
} else {
next = clampInt(parsed - 1, min, Integer.MAX_VALUE);
}
applyValueToUi(slider, input, min, max, allowInf, positions, next, updating);
prefs.edit().putInt(key, next).apply();
SbtSettings.ensureReadable(requireContext());
});
plus.setOnClickListener(v -> {
int parsed = parseInput(input, current);
int next;
if (parsed == INF_VALUE) {
next = INF_VALUE;
} else {
next = clampInt(parsed + 1, min, Integer.MAX_VALUE);
}
applyValueToUi(slider, input, min, max, allowInf, positions, next, updating);
prefs.edit().putInt(key, next).apply();
SbtSettings.ensureReadable(requireContext());
});
input.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE) {
int next = clampInt(parseInput(input, current), min, Integer.MAX_VALUE);
applyValueToUi(slider, input, min, max, allowInf, positions, next, updating);
prefs.edit().putInt(key, next).apply();
SbtSettings.ensureReadable(requireContext());
}
return false;
});
input.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) { }
@Override
public void afterTextChanged(Editable s) {
if (updating[0]) {
return;
}
String text = s != null ? s.toString().trim() : "";
if (text.isEmpty()) {
return;
}
int parsed = parseInput(input, current);
int next = clampInt(parsed, min, Integer.MAX_VALUE);
if (next != parsed) {
applyValueToUi(slider, input, min, max, allowInf, positions, next, updating);
} else {
updateSliderValue(slider, min, max, allowInf, positions, next, updating);
}
prefs.edit().putInt(key, next).apply();
SbtSettings.ensureReadable(requireContext());
}
});
}
private void bindSwitch(View root,
SharedPreferences prefs,
int switchId,
String key,
boolean defaultValue) {
SwitchMaterial toggle = root.findViewById(switchId);
boolean current = prefs.getBoolean(key, defaultValue);
toggle.setChecked(current);
toggle.setOnCheckedChangeListener((buttonView, isChecked) ->
{
prefs.edit().putBoolean(key, isChecked).apply();
SbtSettings.ensureReadable(requireContext());
});
}
private int clampInt(int value, int min, int max) {
if (value < min) {
return min;
}
if (value > max) {
return max;
}
return value;
}
private int parseInput(EditText input, int fallback) {
if (input == null) {
return fallback;
}
String text = input.getText().toString().trim();
if (text.isEmpty()) {
return fallback;
}
if (INF_LABEL.equals(text) || "inf".equalsIgnoreCase(text)) {
return INF_VALUE;
}
return Integer.parseInt(text);
}
private void applyValueToUi(Slider slider,
EditText input,
int min,
int max,
boolean allowInf,
int[] positions,
int value,
boolean[] updating) {
updateSliderValue(slider, min, max, allowInf, positions, value, updating);
updating[0] = true;
if (value == INF_VALUE) {
input.setText(INF_LABEL);
} else {
input.setText(String.valueOf(value));
}
updating[0] = false;
}
private void updateSliderValue(Slider slider,
int min,
int max,
boolean allowInf,
int[] positions,
int value,
boolean[] updating) {
int idx = valueToPosition(value, min, max, allowInf, positions);
updating[0] = true;
slider.setValue(idx);
updating[0] = false;
}
private int[] buildPositions(int min, int max) {
int count = max - min + 1;
int maxPos = SLIDER_STEPS - INF_GAP_STEPS;
int[] positions = new int[count];
if (count <= 1) {
positions[0] = 0;
return positions;
}
for (int i = 0; i < count; i++) {
double t = (double) i / (double) (count - 1);
double shaped = Math.pow(t, 1.0 / SLIDER_EXPONENT);
positions[i] = (int) Math.round(shaped * maxPos);
}
positions[0] = 0;
positions[count - 1] = maxPos;
for (int i = 1; i < count; i++) {
if (positions[i] <= positions[i - 1]) {
positions[i] = positions[i - 1] + 1;
}
}
positions[count - 1] = maxPos;
for (int i = count - 2; i >= 0; i--) {
if (positions[i] >= positions[i + 1]) {
positions[i] = positions[i + 1] - 1;
}
}
return positions;
}
private int valueToPosition(int value, int min, int max, boolean allowInf, int[] positions) {
if (allowInf && value == INF_VALUE) {
return SLIDER_STEPS;
}
if (allowInf && value > max) {
return SLIDER_STEPS;
}
int clamped = clampInt(value, min, max);
return positions[clamped - min];
}
private int positionToValue(int pos,
int min,
int max,
boolean allowInf,
int[] positions,
int maxPos) {
if (allowInf && pos >= maxPos + (INF_GAP_STEPS / 2)) {
return INF_VALUE;
}
int idx = nearestIndex(pos, positions);
int value = min + idx;
return clampInt(value, min, max);
}
private int nearestIndex(int pos, int[] positions) {
int idx = Arrays.binarySearch(positions, pos);
if (idx >= 0) {
return idx;
}
int insert = -idx - 1;
if (insert <= 0) {
return 0;
}
if (insert >= positions.length) {
return positions.length - 1;
}
int left = positions[insert - 1];
int right = positions[insert];
return (pos - left <= right - pos) ? (insert - 1) : insert;
}
private int snapPosition(int pos, int[] positions, boolean allowInf, int maxPos) {
if (allowInf && pos >= maxPos + (INF_GAP_STEPS / 2)) {
return SLIDER_STEPS;
}
int idx = nearestIndex(pos, positions);
return positions[idx];
}
private String labelForSliderValue(int pos, int min, int max, boolean allowInf, int[] positions) {
int maxPos = SLIDER_STEPS - INF_GAP_STEPS;
int value = positionToValue(pos, min, max, allowInf, positions, maxPos);
return value == INF_VALUE ? INF_LABEL : String.valueOf(value);
}
}
@@ -0,0 +1,53 @@
package se.ajpanton.statusbartweak.shell.ui;
import se.ajpanton.statusbartweak.R;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.google.android.material.switchmaterial.SwitchMaterial;
public class StatusChipsFragment extends Fragment {
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_status_chips, container, false);
SharedPreferences prefs = requireContext()
.getSharedPreferences(SbtSettings.PREFS_NAME, Context.MODE_PRIVATE);
SbtSettings.ensureReadable(requireContext());
bindSwitch(root, prefs, R.id.status_chips_hide_all_switch,
SbtSettings.KEY_STATUS_CHIPS_HIDE_ALL, false);
bindSwitch(root, prefs, R.id.status_chips_hide_media_unlocked_switch,
SbtSettings.KEY_STATUS_CHIPS_HIDE_MEDIA_UNLOCKED, false);
bindSwitch(root, prefs, R.id.status_chips_hide_navigation_unlocked_switch,
SbtSettings.KEY_STATUS_CHIPS_HIDE_NAV_UNLOCKED, false);
bindSwitch(root, prefs, R.id.status_chips_hide_call_unlocked_switch,
SbtSettings.KEY_STATUS_CHIPS_HIDE_CALL, false);
return root;
}
private void bindSwitch(View root,
SharedPreferences prefs,
int switchId,
String key,
boolean defaultValue) {
SwitchMaterial toggle = root.findViewById(switchId);
toggle.setChecked(prefs.getBoolean(key, defaultValue));
toggle.setOnCheckedChangeListener((buttonView, isChecked) -> {
prefs.edit().putBoolean(key, isChecked).commit();
SbtSettings.ensureReadable(requireContext());
});
}
}
@@ -0,0 +1,443 @@
package se.ajpanton.statusbartweak.shell.ui;
import se.ajpanton.statusbartweak.R;
import se.ajpanton.statusbartweak.shell.settings.SbtDefaults;
import se.ajpanton.statusbartweak.shell.settings.SbtSettings;
import se.ajpanton.statusbartweak.shell.settings.SystemIconRules;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.ColorStateList;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.NinePatchDrawable;
import android.os.Bundle;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import com.google.android.material.color.MaterialColors;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.HashMap;
public class SystemIconsFragment extends Fragment {
private static final String QUICKSTAR_PACKAGE = "com.samsung.android.qstuner";
private static final String SYSTEMUI_PACKAGE = "com.android.systemui";
private static final int TOGGLE_ICON_DP = 24;
private static final List<SystemIconSection> SECTIONS = Arrays.asList(
new SystemIconSection(
R.string.system_icons_group_connectivity,
Arrays.asList(
new SystemIconEntry(R.string.system_icon_airplane,
new String[]{"stat_sys_airplane_mode"},
android.R.drawable.stat_sys_warning,
"airplane"),
new SystemIconEntry(R.string.system_icon_bluetooth,
new String[]{"stat_sys_data_bluetooth"},
android.R.drawable.stat_sys_data_bluetooth,
"bluetooth", "bluetooth_connected"),
new SystemIconEntry(R.string.system_icon_mobile,
new String[]{"sbt_qs_mobile_signal"},
R.drawable.sbt_qs_mobile_signal,
false,
"mobile"),
new SystemIconEntry(R.string.system_icon_hotspot,
new String[]{"stat_sys_mobile_hotspot"},
R.drawable.sbt_ic_hotspot,
"hotspot"),
new SystemIconEntry(R.string.system_icon_nfc,
new String[]{"stat_sys_nfc"},
android.R.drawable.ic_menu_share,
"nfc_on", "nfc"),
new SystemIconEntry(R.string.system_icon_vpn,
new String[]{"stat_sys_branded_vpn"},
android.R.drawable.ic_secure,
"vpn"),
new SystemIconEntry(R.string.system_icon_wifi,
new String[]{"stat_sys_wifi_signal_3", "stat_sys_wifi_signal_3_pure"},
android.R.drawable.ic_menu_upload,
"wifi")
)
),
new SystemIconSection(
R.string.system_icons_group_notifications,
Arrays.asList(
new SystemIconEntry(R.string.system_icon_alarm,
new String[]{"stat_sys_alarm"},
android.R.drawable.ic_lock_idle_alarm,
"alarm_clock"),
new SystemIconEntry(R.string.system_icon_do_not_disturb,
new String[]{"stat_sys_dnd", "ic_notification_dnd_on", "ic_qs_dnd_on"},
android.R.drawable.presence_busy,
"donotdisturb", "zen", "dnd"),
new SystemIconEntry(R.string.system_icon_volume,
new String[]{"stat_sys_ringer_vibrate"},
android.R.drawable.ic_lock_silent_mode_off,
"volume", "mute")
)
),
new SystemIconSection(
R.string.system_icons_group_system,
Arrays.asList(
new SystemIconEntry(R.string.system_icon_battery_icon,
new String[]{"stat_sys_battery_outline"},
R.drawable.sbt_ic_battery,
true,
"battery", "battery_icon"),
new SystemIconEntry(R.string.system_icon_data_saver,
new String[]{"stat_sys_data_saver"},
android.R.drawable.stat_notify_sync_noanim,
"data_saver"),
new SystemIconEntry(R.string.system_icon_location,
new String[]{"stat_sys_location"},
android.R.drawable.ic_menu_mylocation,
"location")
)
)
);
private Context quickStarContext;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_system_icons, container, false);
SharedPreferences prefs = requireContext()
.getSharedPreferences(SbtSettings.PREFS_NAME, Context.MODE_PRIVATE);
SbtSettings.ensureReadable(requireContext());
Map<String, Integer> blockedModes = new HashMap<>(SbtSettings.from(requireContext()).systemIconBlockedModes);
Map<Integer, LinearLayout> sectionContainers = new HashMap<>();
sectionContainers.put(R.string.system_icons_group_connectivity,
root.findViewById(R.id.system_icons_connectivity_container));
sectionContainers.put(R.string.system_icons_group_notifications,
root.findViewById(R.id.system_icons_notifications_container));
sectionContainers.put(R.string.system_icons_group_system,
root.findViewById(R.id.system_icons_system_container));
for (SystemIconSection section : SECTIONS) {
LinearLayout sectionContainer = sectionContainers.get(section.labelRes);
if (sectionContainer == null) {
continue;
}
for (SystemIconEntry entry : section.entries) {
sectionContainer.addView(createRow(sectionContainer, prefs, blockedModes, entry));
}
}
applyLayoutAvailability(root, prefs);
return root;
}
private void applyLayoutAvailability(View root, SharedPreferences prefs) {
if (root == null || prefs == null) {
return;
}
boolean enabled = prefs.getBoolean(
SbtSettings.KEY_CLOCK_ENABLED,
SbtDefaults.CLOCK_ENABLED_DEFAULT);
View content = root.findViewById(R.id.system_icons_content);
TextView disabledHint = root.findViewById(R.id.system_icons_disabled_hint);
if (content != null) {
setViewTreeEnabled(content, enabled);
content.setAlpha(enabled ? 1f : 0.45f);
}
if (disabledHint != null) {
disabledHint.setVisibility(enabled ? View.GONE : View.VISIBLE);
disabledHint.setAlpha(1f);
}
}
private void setViewTreeEnabled(View view, boolean enabled) {
if (view == null) {
return;
}
view.setEnabled(enabled);
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int i = 0; i < group.getChildCount(); i++) {
setViewTreeEnabled(group.getChildAt(i), enabled);
}
}
}
private View createRow(ViewGroup parent,
SharedPreferences prefs,
Map<String, Integer> blockedModes,
SystemIconEntry entry) {
Context context = parent.getContext();
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setLayoutParams(new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT));
row.setPadding(0, dpToPx(context, 6), 0, dpToPx(context, 6));
LinearLayout modeStrip = new LinearLayout(context);
modeStrip.setOrientation(LinearLayout.HORIZONTAL);
LinearLayout.LayoutParams modeStripParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
modeStrip.setLayoutParams(modeStripParams);
modeStrip.addView(createModeToggle(context, prefs, blockedModes, entry,
SystemIconRules.MODE_AOD, R.string.hidden_apps_mode_aod));
modeStrip.addView(createModeToggle(context, prefs, blockedModes, entry,
SystemIconRules.MODE_LOCK, R.string.hidden_apps_mode_lock_short));
modeStrip.addView(createModeToggle(context, prefs, blockedModes, entry,
SystemIconRules.MODE_UNLOCK, R.string.hidden_apps_mode_unlock_short));
row.addView(modeStrip);
TextView label = new TextView(context);
LinearLayout.LayoutParams labelParams = new LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT);
labelParams.weight = 1f;
labelParams.leftMargin = dpToPx(context, 8);
label.setLayoutParams(labelParams);
label.setText(context.getString(entry.labelRes));
label.setTextAppearance(com.google.android.material.R.style.TextAppearance_MaterialComponents_Body1);
row.addView(label);
return row;
}
private View createModeToggle(Context context,
SharedPreferences prefs,
Map<String, Integer> blockedModes,
SystemIconEntry entry,
int modeMask,
int modeLabelRes) {
LinearLayout cell = new LinearLayout(context);
cell.setOrientation(LinearLayout.HORIZONTAL);
cell.setGravity(Gravity.CENTER);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
dpToPx(context, 32), ViewGroup.LayoutParams.WRAP_CONTENT);
cell.setLayoutParams(params);
FrameLayout iconCell = new FrameLayout(context);
LinearLayout.LayoutParams iconCellParams = new LinearLayout.LayoutParams(
dpToPx(context, TOGGLE_ICON_DP),
dpToPx(context, TOGGLE_ICON_DP));
iconCell.setLayoutParams(iconCellParams);
ImageView iconView = new ImageView(context);
FrameLayout.LayoutParams iconParams = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT);
iconView.setLayoutParams(iconParams);
iconView.setScaleType(ImageView.ScaleType.FIT_CENTER);
Drawable iconDrawable = resolveIconDrawable(context, entry);
if (iconDrawable != null) {
iconView.setImageDrawable(applyBestEffortTint(context, iconDrawable, entry.tintBitmap));
}
iconCell.addView(iconView);
TextView cross = new TextView(context);
FrameLayout.LayoutParams crossParams = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT);
cross.setLayoutParams(crossParams);
cross.setGravity(Gravity.CENTER);
cross.setText("\u2715");
cross.setTextSize(14f);
cross.setTextColor(ContextCompat.getColor(context, R.color.sbt_hidden_cross));
iconCell.addView(cross);
cell.addView(iconCell);
cell.setContentDescription(context.getString(modeLabelRes));
boolean blocked = true;
for (String slot : entry.slots) {
if ((modeMask & SystemIconRules.MODE_AOD) != 0
&& !SystemIconRules.isBlocked(blockedModes, slot, "AOD")) {
blocked = false;
break;
}
if ((modeMask & SystemIconRules.MODE_LOCK) != 0
&& !SystemIconRules.isBlocked(blockedModes, slot, "LOCK")) {
blocked = false;
break;
}
if ((modeMask & SystemIconRules.MODE_UNLOCK) != 0
&& !SystemIconRules.isBlocked(blockedModes, slot, "UNLOCK")) {
blocked = false;
break;
}
}
applyToggleCellState(iconCell, cross, blocked);
cell.setOnClickListener(v -> {
boolean nextBlocked = !(Boolean.TRUE.equals(iconCell.getTag()));
for (String slot : entry.slots) {
SystemIconRules.setModeBlocked(blockedModes, slot, modeMask, nextBlocked);
}
persistBlockedModes(prefs, blockedModes);
applyToggleCellState(iconCell, cross, nextBlocked);
});
return cell;
}
private void persistBlockedModes(SharedPreferences prefs, Map<String, Integer> blockedModes) {
Set<String> encoded = SystemIconRules.encodeBlockedModes(blockedModes);
prefs.edit()
.putStringSet(SbtSettings.KEY_SYSTEM_ICON_BLOCKED_MODES, encoded)
// Clear legacy key so migration does not keep re-forcing stale global hides.
.putStringSet(SbtSettings.KEY_SYSTEM_ICON_HIDE_SLOTS, Collections.emptySet())
.apply();
SbtSettings.ensureReadable(requireContext());
}
private void applyToggleCellState(View iconCell, TextView cross, boolean blocked) {
if (iconCell != null) {
iconCell.setTag(blocked);
iconCell.setAlpha(blocked ? 0.45f : 1f);
}
if (cross != null) {
cross.setVisibility(blocked ? View.VISIBLE : View.GONE);
}
}
private Drawable resolveIconDrawable(Context context, SystemIconEntry entry) {
if (entry == null || context == null) {
return null;
}
if (entry.quickStarDrawableNames != null) {
for (String name : entry.quickStarDrawableNames) {
Drawable drawable = loadPackageDrawable(context, QUICKSTAR_PACKAGE, name);
if (drawable == null) {
drawable = loadPackageDrawable(context, SYSTEMUI_PACKAGE, name);
}
if (drawable != null) {
return drawable;
}
}
}
if (entry.fallbackDrawableRes != 0) {
Drawable fallback = ContextCompat.getDrawable(context, entry.fallbackDrawableRes);
return cloneDrawable(fallback, context);
}
return null;
}
private Drawable loadPackageDrawable(Context context, String packageName, String drawableName) {
if (context == null || drawableName == null || drawableName.trim().isEmpty()) {
return null;
}
Context packageContext = getPackageContext(context, packageName);
if (packageContext == null) {
return null;
}
int id = packageContext.getResources().getIdentifier(drawableName, "drawable", packageName);
if (id == 0) {
return null;
}
Drawable drawable = ContextCompat.getDrawable(packageContext, id);
return cloneDrawable(drawable, packageContext);
}
private Context getPackageContext(Context context, String packageName) {
if (context == null || packageName == null) {
return null;
}
if (QUICKSTAR_PACKAGE.equals(packageName) && quickStarContext != null) {
return quickStarContext;
}
try {
Context pkgContext = context.createPackageContext(packageName, Context.CONTEXT_IGNORE_SECURITY);
if (QUICKSTAR_PACKAGE.equals(packageName)) {
quickStarContext = pkgContext;
}
return pkgContext;
} catch (Throwable ignored) {
if (QUICKSTAR_PACKAGE.equals(packageName)) {
quickStarContext = null;
}
return null;
}
}
private Drawable cloneDrawable(Drawable drawable, Context context) {
if (drawable == null) {
return null;
}
try {
Drawable.ConstantState state = drawable.getConstantState();
if (state != null && context != null) {
Drawable copy = state.newDrawable(context.getResources(), context.getTheme());
return copy != null ? copy.mutate() : drawable.mutate();
}
} catch (Throwable ignored) {
}
return drawable.mutate();
}
private Drawable applyBestEffortTint(Context context, Drawable drawable, boolean tintBitmap) {
if (drawable == null) {
return null;
}
if (!tintBitmap && (drawable instanceof BitmapDrawable || drawable instanceof NinePatchDrawable)) {
// Many OEM bitmap assets become solid squares if tinted.
return drawable;
}
int tint = MaterialColors.getColor(
context,
com.google.android.material.R.attr.colorOnSurface,
0xFFFFFFFF);
drawable.setTintList(ColorStateList.valueOf(tint));
return drawable;
}
private int dpToPx(Context context, int dp) {
float density = context.getResources().getDisplayMetrics().density;
return Math.round(dp * density);
}
private static final class SystemIconSection {
final int labelRes;
final List<SystemIconEntry> entries;
SystemIconSection(int labelRes, List<SystemIconEntry> entries) {
this.labelRes = labelRes;
this.entries = entries;
}
}
private static final class SystemIconEntry {
final int labelRes;
final String[] quickStarDrawableNames;
final int fallbackDrawableRes;
final boolean tintBitmap;
final Set<String> slots;
SystemIconEntry(int labelRes, String[] quickStarDrawableNames, int fallbackDrawableRes, String... slots) {
this(labelRes, quickStarDrawableNames, fallbackDrawableRes, false, slots);
}
SystemIconEntry(int labelRes,
String[] quickStarDrawableNames,
int fallbackDrawableRes,
boolean tintBitmap,
String... slots) {
this.labelRes = labelRes;
this.quickStarDrawableNames = quickStarDrawableNames;
this.fallbackDrawableRes = fallbackDrawableRes;
this.tintBitmap = tintBitmap;
this.slots = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(slots)));
}
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="@android:color/white" />
<item android:color="?android:textColorPrimary" />
</selector>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true">
<shape android:shape="rectangle">
<corners android:radius="12dp" />
<stroke
android:width="1dp"
android:color="#FF03DAC5" />
<solid android:color="#5503DAC5" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" />
</shape>
</item>
</selector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M8,7l-5,5l5,5l1.41,-1.41L6.83,13H17.17l-2.58,2.59L16,17l5,-5l-5,-5l-1.41,1.41L17.17,11H6.83l2.58,-2.59z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M10,7l5,5l-5,5l-1.41,-1.41L11.17,13H5v-2h6.17L8.59,8.41z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M14,7l-5,5l5,5l1.41,-1.41L12.83,13H19v-2H12.83l2.58,-2.59z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M10,2h4c0.55,0 1,0.45 1,1v1h1c1.1,0 2,0.9 2,2v14c0,1.1 -0.9,2 -2,2H8c-1.1,0 -2,-0.9 -2,-2V6c0,-1.1 0.9,-2 2,-2h1V3c0,-0.55 0.45,-1 1,-1zM11,4h2V3h-2v1zM8,6v14h8V6H8z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M10,9h4v8h-4z" />
</vector>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,15.6a1.6,1.6 0,1 0,0 3.2a1.6,1.6 0,1 0,0 -3.2z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,11.4c-2.05,0 -3.93,0.77 -5.36,2.03l1.06,1.21c1.15,-1 2.65,-1.62 4.3,-1.62s3.15,0.62 4.3,1.62l1.06,-1.21C15.93,12.17 14.05,11.4 12,11.4z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,7.2c-3.2,0 -6.12,1.2 -8.34,3.18l1.06,1.21C6.66,9.88 9.2,8.84 12,8.84s5.34,1.04 7.28,2.75l1.06,-1.21C18.12,8.4 15.2,7.2 12,7.2z" />
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="15dp"
android:height="14dp"
android:viewportWidth="15"
android:viewportHeight="14">
<path
android:fillColor="#ff000000"
android:fillAlpha="0.3961"
android:pathData="M12,1.719C11.632,1.719 11.333,2.018 11.333,2.386L11.333,2.447L11.333,11.679L11.333,12.351C11.333,12.72 11.632,13.018 12,13.018C12.368,13.018 12.667,12.72 12.667,12.351L12.667,11.679L12.667,2.447L12.667,2.386C12.667,2.018 12.368,1.719 12,1.719Z" />
<path
android:fillColor="#ff000000"
android:pathData="M7.5,6.053C7.868,6.053 8.167,6.352 8.167,6.719L8.167,6.719L8.167,12.352C8.167,12.721 7.868,13.019 7.5,13.019C7.131,13.019 6.833,12.721 6.833,12.352L6.833,12.352L6.833,6.72C6.833,6.352 7.131,6.053 7.5,6.053ZM5.25,8.386C5.618,8.386 5.917,8.685 5.917,9.053L5.917,9.053L5.917,12.351C5.917,12.72 5.618,13.018 5.25,13.018C4.881,13.018 4.583,12.72 4.583,12.351L4.583,12.351L4.583,9.053C4.583,8.685 4.881,8.386 5.25,8.386ZM3,10.386C3.368,10.386 3.667,10.685 3.667,11.053L3.667,11.053L3.667,12.351C3.667,12.72 3.368,13.018 3,13.018C2.631,13.018 2.333,12.72 2.333,12.351L2.333,12.351L2.333,11.053C2.333,10.685 2.631,10.386 3,10.386ZM9.75,3.719C10.118,3.719 10.417,4.018 10.417,4.386L10.417,4.386L10.417,12.351C10.417,12.72 10.118,13.018 9.75,13.018C9.382,13.018 9.083,12.72 9.083,12.351L9.083,12.351L9.083,4.386C9.083,4.018 9.382,3.719 9.75,3.719Z" />
</vector>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#12FFFFFF" />
<corners android:radius="12dp" />
<stroke
android:width="1dp"
android:color="@color/sbt_panel_outline" />
</shape>
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorPrimary"
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar"
app:titleTextColor="@android:color/white" />
<FrameLayout
android:id="@+id/content_frame"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/drawer_header"
app:itemBackground="@drawable/drawer_item_background"
app:itemTextColor="@color/drawer_item_text_color"
app:menu="@menu/drawer_menu" />
</androidx.drawerlayout.widget.DrawerLayout>
@@ -0,0 +1,202 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls"
android:padding="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="0dp"
android:paddingBottom="0dp">
<TextView
android:id="@+id/hidden_app_config_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="2dp"
android:textStyle="bold"
android:textSize="18sp"
android:paddingTop="0dp"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/hidden_app_config_default_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="2dp"
android:text="@string/hidden_apps_default_modes_title"
android:textStyle="bold"
android:paddingBottom="4dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="2dp">
<Space
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<LinearLayout
android:id="@+id/hidden_app_default_aod_cell"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="2dp">
<FrameLayout
android:layout_width="24dp"
android:layout_height="24dp">
<ImageView
android:id="@+id/hidden_app_default_aod_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:scaleType="fitCenter" />
<TextView
android:id="@+id/hidden_app_default_aod_cross"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="✕"
android:textColor="@color/sbt_hidden_cross"
android:textSize="14sp"
android:textStyle="bold"
android:visibility="gone" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/hidden_apps_mode_aod"
android:textSize="11sp"
android:maxLines="1"
android:singleLine="true" />
</LinearLayout>
<Space
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<LinearLayout
android:id="@+id/hidden_app_default_lock_cell"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="2dp">
<FrameLayout
android:layout_width="24dp"
android:layout_height="24dp">
<ImageView
android:id="@+id/hidden_app_default_lock_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:scaleType="fitCenter" />
<TextView
android:id="@+id/hidden_app_default_lock_cross"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="✕"
android:textColor="@color/sbt_hidden_cross"
android:textSize="14sp"
android:textStyle="bold"
android:visibility="gone" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/hidden_apps_mode_lock"
android:textSize="11sp"
android:maxLines="1"
android:singleLine="true" />
</LinearLayout>
<Space
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<LinearLayout
android:id="@+id/hidden_app_default_unlock_cell"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="2dp">
<FrameLayout
android:layout_width="24dp"
android:layout_height="24dp">
<ImageView
android:id="@+id/hidden_app_default_unlock_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:scaleType="fitCenter" />
<TextView
android:id="@+id/hidden_app_default_unlock_cross"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="✕"
android:textColor="@color/sbt_hidden_cross"
android:textSize="14sp"
android:textStyle="bold"
android:visibility="gone" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/hidden_apps_mode_unlock"
android:textSize="11sp"
android:maxLines="1"
android:singleLine="true" />
</LinearLayout>
<Space
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/hidden_app_exceptions_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingBottom="2dp"
android:overScrollMode="ifContentScrolls" />
<com.google.android.material.button.MaterialButton
android:id="@+id/hidden_app_add_exception"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginBottom="0dp"
android:text="@string/hidden_apps_add_exception"
android:minHeight="40dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="160dp"
android:background="?attr/colorPrimary"
android:gravity="bottom"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAppearance="?attr/textAppearanceHeadline6"
android:textColor="@android:color/white" />
</LinearLayout>
@@ -0,0 +1,548 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/battery_bar_title"
android:textAppearance="?attr/textAppearanceHeadline5" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardUseCompatPadding="true"
app:strokeColor="@color/sbt_card_outline"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/battery_bar_master_toggle_title"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/battery_bar_enabled_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/battery_bar_enabled" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:id="@+id/battery_bar_geometry_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardUseCompatPadding="true"
app:strokeColor="@color/sbt_card_outline"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/battery_bar_position_title"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<RadioGroup
android:id="@+id/battery_bar_position_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<RadioButton
android:id="@+id/battery_bar_position_top"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/battery_bar_position_top" />
<RadioButton
android:id="@+id/battery_bar_position_bottom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/battery_bar_position_bottom" />
</RadioGroup>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/battery_bar_thickness_title"
android:textAppearance="?attr/textAppearanceBody2" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/battery_bar_thickness_minus"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="48dp"
android:layout_height="wrap_content"
android:text="-" />
<EditText
android:id="@+id/battery_bar_thickness_input"
android:layout_width="72dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ems="3"
android:gravity="center"
android:importantForAutofill="no"
android:inputType="number"
android:maxLines="1"
android:singleLine="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/battery_bar_thickness_plus"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="48dp"
android:layout_height="wrap_content"
android:text="+" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/battery_bar_edge_offset_title"
android:textAppearance="?attr/textAppearanceBody2" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/battery_bar_edge_offset_minus"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="48dp"
android:layout_height="wrap_content"
android:text="-" />
<EditText
android:id="@+id/battery_bar_edge_offset_input"
android:layout_width="72dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ems="3"
android:gravity="center"
android:importantForAutofill="no"
android:inputType="number"
android:maxLines="1"
android:singleLine="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/battery_bar_edge_offset_plus"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="48dp"
android:layout_height="wrap_content"
android:text="+" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardUseCompatPadding="true"
app:strokeColor="@color/sbt_card_outline"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/battery_bar_alignment_title"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/battery_bar_alignment_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/battery_bar_alignment_cell_ltr"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="4dp">
<RadioButton
android:id="@+id/battery_bar_alignment_ltr"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:minHeight="0dp"
android:minWidth="0dp"
android:padding="0dp" />
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginTop="4dp"
android:contentDescription="@null"
app:srcCompat="@drawable/sbt_align_ltr" />
</LinearLayout>
<LinearLayout
android:id="@+id/battery_bar_alignment_cell_center"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="4dp">
<RadioButton
android:id="@+id/battery_bar_alignment_center"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:minHeight="0dp"
android:minWidth="0dp"
android:padding="0dp" />
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginTop="4dp"
android:contentDescription="@null"
app:srcCompat="@drawable/sbt_align_center" />
</LinearLayout>
<LinearLayout
android:id="@+id/battery_bar_alignment_cell_rtl"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="4dp">
<RadioButton
android:id="@+id/battery_bar_alignment_rtl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:minHeight="0dp"
android:minWidth="0dp"
android:padding="0dp" />
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginTop="4dp"
android:contentDescription="@null"
app:srcCompat="@drawable/sbt_align_rtl" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardUseCompatPadding="true"
app:strokeColor="@color/sbt_card_outline"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/battery_bar_mapping_title"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/battery_bar_mapping_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<LinearLayout
android:id="@+id/battery_bar_mapping_left_controls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/battery_bar_min_level_plus"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="48dp"
android:layout_height="36dp"
android:layout_marginBottom="1dp"
android:minHeight="0dp"
android:paddingTop="0dp"
android:paddingBottom="0dp"
android:text="+" />
<com.google.android.material.button.MaterialButton
android:id="@+id/battery_bar_min_level_minus"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="48dp"
android:layout_height="36dp"
android:layout_marginTop="1dp"
android:minHeight="0dp"
android:paddingTop="0dp"
android:paddingBottom="0dp"
android:text="-" />
</LinearLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/battery_bar_mapping_middle_section"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/battery_bar_mapping_right_controls"
app:layout_constraintStart_toEndOf="@id/battery_bar_mapping_left_controls"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/battery_bar_mapping_slider_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/battery_bar_mapping_slider_label"
android:textAppearance="?attr/textAppearanceCaption"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.slider.RangeSlider
android:id="@+id/battery_bar_mapping_slider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:valueFrom="0"
android:valueTo="100"
app:labelBehavior="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.5" />
<TextView
android:id="@+id/battery_bar_min_level_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceCaption"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/battery_bar_max_level_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="end"
android:textAppearance="?attr/textAppearanceCaption"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:id="@+id/battery_bar_mapping_right_controls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/battery_bar_max_level_plus"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="48dp"
android:layout_height="36dp"
android:layout_marginBottom="1dp"
android:minHeight="0dp"
android:paddingTop="0dp"
android:paddingBottom="0dp"
android:text="+" />
<com.google.android.material.button.MaterialButton
android:id="@+id/battery_bar_max_level_minus"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="48dp"
android:layout_height="36dp"
android:layout_marginTop="1dp"
android:minHeight="0dp"
android:paddingTop="0dp"
android:paddingBottom="0dp"
android:text="-" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
<LinearLayout
android:id="@+id/battery_bar_style_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardUseCompatPadding="true"
app:strokeColor="@color/sbt_card_outline"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/battery_bar_colours_title"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButton
android:id="@+id/battery_bar_default_discharge_button"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/battery_bar_default_discharge_colour" />
<com.google.android.material.button.MaterialButton
android:id="@+id/battery_bar_default_charge_button"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/battery_bar_default_charge_colour" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardUseCompatPadding="true"
app:strokeColor="@color/sbt_card_outline"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/battery_bar_thresholds_title"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/battery_bar_thresholds_hint"
android:textAppearance="?attr/textAppearanceBody2" />
<LinearLayout
android:id="@+id/battery_bar_thresholds_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
<com.google.android.material.button.MaterialButton
android:id="@+id/battery_bar_add_threshold_button"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/battery_bar_add_threshold" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/hidden_apps_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/hidden_apps_page_title"
android:textAppearance="?attr/textAppearanceHeadline6"
android:textStyle="bold" />
<TextView
android:id="@+id/hidden_apps_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/hidden_apps_dialog_subtitle"
android:textAppearance="?attr/textAppearanceBody2" />
<TextView
android:id="@+id/hidden_apps_disabled_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/layout_subpage_disabled_hint"
android:textAppearance="?attr/textAppearanceSubtitle2"
android:textStyle="bold"
android:visibility="gone" />
<LinearLayout
android:id="@+id/hidden_apps_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/hidden_apps_page_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/hidden_apps_page_hint"
android:textAppearance="?attr/textAppearanceCaption" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/hidden_apps_swipe"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="10dp"
android:layout_weight="1">
<ListView
android:id="@+id/hidden_apps_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:dividerHeight="0dp"
android:listSelector="@android:color/transparent"
android:paddingBottom="8dp"
android:scrollbarStyle="insideInset" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<TextView
android:id="@+id/hidden_apps_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_horizontal"
android:text="@string/hidden_apps_empty"
android:textAppearance="?attr/textAppearanceBody2"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/hidden_apps_clear_session"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/hidden_apps_clear_session" />
</LinearLayout>
</LinearLayout>
+131
View File
@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/clock_title"
android:textAppearance="?attr/textAppearanceHeadline5" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardUseCompatPadding="true"
app:strokeColor="@color/sbt_card_outline"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/clock_custom_format_title"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<CheckBox
android:id="@+id/clock_custom_format_enabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/clock_custom_format_enabled" />
<LinearLayout
android:id="@+id/clock_custom_format_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<EditText
android:id="@+id/clock_custom_format_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="top|start"
android:hint="@string/clock_custom_format_hint"
android:importantForAutofill="no"
android:inputType="textMultiLine|textNoSuggestions"
android:maxLines="4"
android:minLines="2"
android:scrollHorizontally="false"
android:singleLine="false" />
<com.google.android.material.button.MaterialButton
android:id="@+id/clock_font_picker_button"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/clock_font_picker_button" />
<TextView
android:id="@+id/clock_custom_format_help_toggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/clock_custom_format_help_show"
android:textAppearance="?attr/textAppearanceCaption" />
<LinearLayout
android:id="@+id/clock_custom_format_help_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:id="@+id/clock_custom_format_help"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/clock_custom_format_help_common"
android:textAppearance="?attr/textAppearanceCaption" />
<TextView
android:id="@+id/clock_custom_format_help_styling"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/clock_custom_format_help_styling"
android:textAppearance="?attr/textAppearanceCaption" />
<TextView
android:id="@+id/clock_custom_format_help_examples"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/clock_custom_format_help_examples"
android:textAppearance="?attr/textAppearanceCaption" />
<TextView
android:id="@+id/clock_custom_format_help_link"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:autoLink="web"
android:linksClickable="true"
android:text="@string/clock_custom_format_help_link"
android:textAppearance="?attr/textAppearanceCaption" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>
@@ -0,0 +1,342 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/debug_title"
android:textAppearance="?attr/textAppearanceHeadline5" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardUseCompatPadding="true"
app:strokeColor="@color/sbt_card_outline"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/debug_section_visualizers"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/debug_visualize_notification_container_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/debug_visualize_notification_container" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/debug_visualize_status_container_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/debug_visualize_status_container" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/debug_visualize_clock_container_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/debug_visualize_clock_container" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/debug_visualize_chip_container_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/debug_visualize_chip_container" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/debug_visualize_camera_cutout_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/debug_visualize_camera_cutout" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardUseCompatPadding="true"
app:strokeColor="@color/sbt_card_outline"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/debug_section_systemui"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButton
android:id="@+id/debug_restart_systemui_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/debug_restart_systemui" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardUseCompatPadding="true"
app:strokeColor="@color/sbt_card_outline"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/debug_section_camera"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<TextView
android:id="@+id/debug_camera_current_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/debug_camera_current_title"
android:textAppearance="?attr/textAppearanceSubtitle2"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<TextView
android:id="@+id/debug_camera_current_identified"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="?attr/textAppearanceBody2" />
<LinearLayout
android:id="@+id/debug_camera_current_override_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/debug_camera_current_action"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="0dp" />
<TextView
android:id="@+id/debug_camera_current_override"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:textAppearance="?attr/textAppearanceBody2" />
</LinearLayout>
<LinearLayout
android:id="@+id/debug_camera_other_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:id="@+id/debug_camera_other_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/debug_camera_other_title"
android:textAppearance="?attr/textAppearanceSubtitle2"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/debug_camera_other_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardUseCompatPadding="true"
app:strokeColor="@color/sbt_card_outline"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/debug_section_logging"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/debug_write_logs_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/debug_write_logs_label" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardUseCompatPadding="true"
app:strokeColor="@color/sbt_card_outline"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/debug_section_notification_icons"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textAllCaps="true"
android:letterSpacing="0.03"
android:textStyle="bold" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/debug_cycle_icons_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/debug_cycle_icons_label" />
<TextView
android:id="@+id/debug_cycle_icons_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/debug_cycle_icons_hint"
android:textAppearance="?attr/textAppearanceBody2" />
<TextView
android:id="@+id/debug_count_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/debug_count_label"
android:textAppearance="?attr/textAppearanceSubtitle2" />
<TextView
android:id="@+id/debug_count_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="0"
android:textAppearance="?attr/textAppearanceHeadline3" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_minus_10"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="-10" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_minus_1"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="-1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_plus_1"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="+1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_plus_10"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="+10" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_reset"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/debug_reset" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More