diff --git a/README.md b/README.md index 21a0cfb..dee9039 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,9 @@ in Cards mode, with separate lockscreen and always-on-display controls for row count, icons per row, balanced rows, width limits, and icon size. In Icons mode, Android—or StatusBarTweak, when installed—continues to render the icons. +The OneUI integration can also keep AOD visible during calls and show the +battery percentage on the lockscreen or AOD while the phone is unplugged. + For visibility control, also give the module the **System UI** scope. Samsung devices need the **Always On Display** scope for AOD filtering and layout. Restart Android after changing module scopes. diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt index 4ba787a..8e60b09 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt @@ -170,6 +170,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte page == Page.NOTIFICATION_VISIBILITY -> NotificationVisibilityFragment() page == Page.CARDS_LAYOUT -> CardsLayoutFragment() page == Page.DEBUG_NOTIFICATIONS -> DebugNotificationsFragment() + page == Page.MISCELLANEOUS -> MiscellaneousFragment() page == Page.LOG_DISPLAY -> LogDisplayFragment() page == Page.FILTER_LOGGING -> FilterLoggingFragment() page == Page.FILTER_APPS -> FilterAppsFragment.newInstance() @@ -377,6 +378,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte NOTIFICATION_VISIBILITY(R.id.nav_notification_visibility, R.string.page_notification_visibility), CARDS_LAYOUT(R.id.nav_cards_layout, R.string.page_cards_layout), DEBUG_NOTIFICATIONS(R.id.nav_debug_notifications, R.string.page_debug_notifications), + MISCELLANEOUS(R.id.nav_miscellaneous, R.string.page_miscellaneous), LOG_DISPLAY(R.id.nav_log_display, R.string.page_log_display), FILTER_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging), FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps), diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/MiscellaneousFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/MiscellaneousFragment.kt new file mode 100644 index 0000000..cb43b46 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/MiscellaneousFragment.kt @@ -0,0 +1,51 @@ +package se.ajpanton.notificationsmaster + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import com.google.android.material.switchmaterial.SwitchMaterial +import se.ajpanton.notificationsmaster.databinding.FragmentMiscellaneousBinding +import se.ajpanton.notificationsmaster.visibility.SystemUiMiscSettings +import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore + +class MiscellaneousFragment : Fragment(R.layout.fragment_miscellaneous) { + private var binding: FragmentMiscellaneousBinding? = null + private lateinit var store: VisibilityPolicyStore + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding = FragmentMiscellaneousBinding.bind(view) + store = VisibilityPolicyStore(requireContext()) + val settings = store.load().systemUiMisc + bind(binding!!.keepAodVisibleDuringCalls, settings.keepAodVisibleDuringCalls) { current, checked -> + current.copy(keepAodVisibleDuringCalls = checked) + } + bind(binding!!.showLockscreenBatteryPercent, settings.showLockscreenBatteryPercentWhenUnplugged) { + current, checked -> + current.copy(showLockscreenBatteryPercentWhenUnplugged = checked) + } + bind(binding!!.showAodBatteryPercent, settings.showAodBatteryPercentWhenUnplugged) { current, checked -> + current.copy(showAodBatteryPercentWhenUnplugged = checked) + } + } + + override fun onResume() { + super.onResume() + activity?.title = getString(R.string.page_miscellaneous) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + private fun bind( + toggle: SwitchMaterial, + initial: Boolean, + update: (SystemUiMiscSettings, Boolean) -> SystemUiMiscSettings, + ) { + toggle.isChecked = initial + toggle.setOnCheckedChangeListener { _, checked -> + store.update { policy -> policy.copy(systemUiMisc = update(policy.systemUiMisc, checked)) } + } + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt index ed7190d..c3e8e96 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt @@ -27,6 +27,7 @@ class NotificationsMasterModule : XposedModule() { OneUiLockscreenBackend(this), OneUiLockscreenCardsBackend(this), SamsungAodPluginBackend(this), + OneUiMiscBackend(this), ).filter { it.install(param.classLoader) } ProcessVisibilityPolicyCache.installWhenReady { systemUiBackends.orEmpty().forEach(VisibilitySurfaceBackend::onPolicyChanged) diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiMiscBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiMiscBackend.kt new file mode 100644 index 0000000..26084ee --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiMiscBackend.kt @@ -0,0 +1,422 @@ +package se.ajpanton.notificationsmaster.module + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.content.res.ColorStateList +import android.media.AudioManager +import android.os.BatteryManager +import android.os.Build +import android.telecom.TelecomManager +import android.telephony.TelephonyManager +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.TextView +import io.github.libxposed.api.XposedInterface +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.text.NumberFormat +import java.util.WeakHashMap + +/** OneUI information-display options ported from StatusBarTweak's Misc page. */ +internal class OneUiMiscBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend { + private val callVisibility = AodCallVisibilityController(framework) + private val lockscreenBattery = LockscreenBatteryController(framework) + private val aodBattery = AodBatteryController(framework) + + override fun install(classLoader: ClassLoader): Boolean { + if (!Build.MANUFACTURER.equals("samsung", ignoreCase = true)) return false + val installed = listOf( + callVisibility.install(classLoader), + lockscreenBattery.install(classLoader), + aodBattery.install(classLoader), + ).count { it } + if (installed > 0) Log.i(TAG, "Installed $installed OneUI miscellaneous controllers") + return installed > 0 + } + + override fun onPolicyChanged() = aodBattery.refresh() + + private companion object { const val TAG = "NotificationsMaster" } +} + +private class AodCallVisibilityController(private val framework: XposedInterface) { + fun install(classLoader: ClassLoader): Boolean { + var hooks = 0 + listOf( + "com.android.systemui.statusbar.phone.CentralSurfacesCommandQueueCallbacks" to "suppressAmbientDisplay", + "com.android.systemui.statusbar.CommandQueue" to "suppressAmbientDisplay", + "com.android.systemui.statusbar.phone.SecLsScrimControlHelper" to "setFrontScrimToBlack", + "com.android.systemui.doze.DozeSuppressor\$1" to "onAlwaysOnSuppressedChanged", + "com.android.systemui.doze.AODUi\$1" to "onAlwaysOnSuppressedChanged", + ).forEach { (className, methodName) -> + classLoader.findClass(className)?.methodsNamed(methodName)?.forEach { method -> + framework.intercept(method) { chain -> + val value = chain.args.firstOrNull() as? Boolean + if (value == true && shouldForceAodForCall()) { + chain.proceed(chain.args.toTypedArray().apply { this[0] = false }) + } else chain.proceed() + } + hooks++ + } + } + classLoader.findClass("com.android.systemui.doze.DozeMachine")?.let { type -> + listOf("requestState", "transitionTo").forEach { name -> + type.methodsNamed(name).forEach { method -> + framework.intercept(method) { chain -> + val requested = chain.args.firstOrNull() as? Enum<*> + val replacement = requested?.takeIf { shouldForceAodForCall() }?.aodReplacement() + if (replacement == null) chain.proceed() else { + chain.proceed(chain.args.toTypedArray().apply { this[0] = replacement }) + } + } + hooks++ + } + } + } + classLoader.findClass("com.android.systemui.plugins.aod.PluginAODSystemUIConfiguration") + ?.methodsNamed("get")?.forEach { method -> + framework.intercept(method) { chain -> + val result = chain.proceed() + if (shouldForceAodForCall() && chain.args.firstOrNull() == PLUGIN_KEY_PHONE_STATE && + (result as? Int ?: chain.args.getOrNull(1) as? Int) != TelephonyManager.CALL_STATE_IDLE + ) TelephonyManager.CALL_STATE_IDLE else result + } + hooks++ + } + return hooks > 0 + } + + private fun Enum<*>.aodReplacement(): Enum<*>? { + if (name != "DOZE" && name != "FINISH") return null + return runCatching { + @Suppress("UNCHECKED_CAST") + java.lang.Enum.valueOf(declaringJavaClass as Class>, "DOZE_AOD") + }.getOrNull() + } + + private fun shouldForceAodForCall(): Boolean { + if (!ProcessVisibilityPolicyCache.systemUiMisc.keepAodVisibleDuringCalls) return false + return currentApplicationContext()?.let(::isInCallLikeState) == true + } + + @SuppressLint("MissingPermission") + @Suppress("DEPRECATION") + private fun isInCallLikeState(context: Context): Boolean { + if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) { + if (runCatching { context.getSystemService(TelecomManager::class.java)?.isInCall == true }.getOrDefault(false)) { + return true + } + if (runCatching { + context.getSystemService(TelephonyManager::class.java)?.callState != + TelephonyManager.CALL_STATE_IDLE + }.getOrDefault(false) + ) return true + } + return runCatching { + context.getSystemService(AudioManager::class.java)?.mode in + setOf(AudioManager.MODE_IN_CALL, AudioManager.MODE_IN_COMMUNICATION) + }.getOrDefault(false) + } + + private companion object { const val PLUGIN_KEY_PHONE_STATE = 0x2 } +} + +private class LockscreenBatteryController(private val framework: XposedInterface) { + private var failed = false + + fun install(classLoader: ClassLoader): Boolean { + val eventClass = classLoader.findClass("com.android.systemui.statusbar.IndicationEventType") ?: return false + val batteryEvent = eventClass.staticField("BATTERY") ?: return false + val restingEvent = eventClass.staticField("BATTERY_RESTING") ?: return false + val controller = classLoader.findClass( + "com.android.systemui.statusbar.KeyguardSecIndicationController", + ) ?: return false + val methods = controller.methodsNamed("addInitialIndication") + methods.forEach { method -> + framework.intercept(method) { chain -> + val result = chain.proceed() + runCatching { + val target = chain.thisObject + val context = target.field("mContext") as? Context ?: return@runCatching + val state = visibleLockscreenBattery(context, target) ?: return@runCatching + val text = state.formattedPercent() + val color = target.field("mInitialTextColorState") as? ColorStateList + target.invokeNamed("addIndicationTimeout", batteryEvent, text, color, false) + target.invokeNamed("addIndication", restingEvent, text) + }.onFailure(::logFailureOnce) + result + } + } + return methods.isNotEmpty() + } + + private fun visibleLockscreenBattery(context: Context, controller: Any): BatteryState? { + if (!ProcessVisibilityPolicyCache.systemUiMisc.showLockscreenBatteryPercentWhenUnplugged) return null + if (controller.field("mDozing") == true) return null + return currentBatteryState(context)?.takeUnless { it.plugged || it.charged } + } + + private fun logFailureOnce(error: Throwable) { + if (failed) return + failed = true + Log.w(TAG, "Could not update the OneUI lockscreen battery indication", error) + } +} + +private class AodBatteryController(private val framework: XposedInterface) { + private var manager: Any? = null + private var host: FrameLayout? = null + private val pendingRetries = mutableSetOf() + private val savedText = WeakHashMap>() + private val savedVisibility = WeakHashMap() + private var failed = false + + fun install(classLoader: ClassLoader): Boolean { + var hooks = 0 + classLoader.findClass("com.android.systemui.doze.PluginAODManager\$6") + ?.methodsNamed("setBottomArea")?.forEach { method -> + framework.intercept(method) { chain -> + val result = chain.proceed() + manager = chain.thisObject.field("this\$0") + host = resolveBottomDozeArea(manager) + update(host, chain.args.firstOrNull { it is View } as? View ?: result as? View) + result + } + hooks++ + } + classLoader.findClass("com.android.systemui.doze.PluginAODManager") + ?.methodsNamed("setIsDozing")?.forEach { method -> + framework.intercept(method) { chain -> + val result = chain.proceed() + manager = chain.thisObject + host = resolveBottomDozeArea(manager) + val target = host?.getChildAt(0) + if (chain.args.firstOrNull() == true) update(host, target) else clear(host, target) + result + } + hooks++ + } + classLoader.findClass( + "com.android.systemui.statusbar.KeyguardSecIndicationController\$SecKeyguardCallback", + )?.methodsNamed("onRefreshBatteryInfo")?.forEach { method -> + framework.intercept(method) { chain -> + val result = chain.proceed() + update(host, host?.getChildAt(0)) + result + } + hooks++ + } + return hooks > 0 + } + + fun refresh() { + host?.post { update(host, host?.getChildAt(0)) } + } + + private fun update(host: FrameLayout?, source: View?) { + runCatching { + val frame = host ?: return + val state = currentBatteryState(frame.context) + if (!ProcessVisibilityPolicyCache.systemUiMisc.showAodBatteryPercentWhenUnplugged || + state == null || state.plugged || state.charged + ) return clear(frame, source) + val text = state.formattedPercent().toString() + val component = pluginRoot(source, frame).field("p") ?: return clear(frame, source) + val componentView = component.field("c") as? View ?: return clear(frame, source) + val indication = componentView.field("d") as? TextView ?: return clear(frame, source) + cancelRetries(frame) + apply(componentView, indication, text) + RETRY_DELAYS.forEach { delay -> + val retry = object : Runnable { + override fun run() { + pendingRetries -= this + if (this@AodBatteryController.host !== frame) return + val current = currentBatteryState(frame.context) + if (!ProcessVisibilityPolicyCache.systemUiMisc.showAodBatteryPercentWhenUnplugged || + current == null || current.plugged || current.charged + ) { + clear(frame, source) + return + } + val root = pluginRoot(source, frame).field("p").field("c") as? View ?: return + val textView = root.field("d") as? TextView ?: return + apply(root, textView, current.formattedPercent().toString()) + } + } + pendingRetries += retry + frame.postDelayed(retry, delay) + } + }.onFailure(::logFailureOnce) + } + + private fun apply(componentView: View, indication: TextView, text: String) { + savedText.putIfAbsent(indication, indication.text to indication.visibility) + indication.text = text + indication.visibility = View.VISIBLE + hide(componentView.field("k") as? View) + hide(componentView.field("c") as? View) + hide(componentView.field("l") as? View) + forceVisible(indication) + forceVisible(componentView.field("b") as? View) + forceVisible(componentView) + } + + private fun clear(host: FrameLayout?, source: View?) { + cancelRetries(host) + val componentView = pluginRoot(source, host).field("p").field("c") as? View + (componentView?.field("d") as? TextView)?.let { view -> + savedText.remove(view)?.let { (text, visibility) -> + view.text = text + view.visibility = visibility + } + } + listOf("k", "c", "l").forEach { name -> + (componentView?.field(name) as? View)?.let { restoreVisibility(it) } + } + } + + private fun hide(view: View?) { + view ?: return + rememberVisibility(view) + view.visibility = View.GONE + } + + private fun rememberVisibility(view: View) { + savedVisibility.putIfAbsent(view, view.visibility) + } + + private fun restoreVisibility(view: View) { + savedVisibility.remove(view)?.let { view.visibility = it } + } + + private fun forceVisible(start: View?) { + var view = start + while (view != null) { + view.visibility = View.VISIBLE + view.alpha = 1f + view.translationX = 0f + view.translationY = 0f + view.scaleX = 1f + view.scaleY = 1f + view.requestLayout() + view.invalidate() + view = view.parent as? View + } + } + + private fun pluginRoot(source: View?, host: FrameLayout?): Any? = when { + source is ViewGroup -> source + host?.childCount ?: 0 > 0 -> host?.getChildAt(0) + else -> null + } + + private fun resolveBottomDozeArea(manager: Any?): FrameLayout? = runCatching { + val panel = manager.field("mPanelViewControllerLazy").invokeNamed("get") + val controller = panel.field("mKeyguardSecBottomAreaViewController") + val bottomView = controller.invokeNamed("getView") + bottomView.field("bottomDozeArea\$delegate").invokeNamed("getValue") as? FrameLayout + }.getOrNull() + + private fun cancelRetries(host: FrameLayout?) { + pendingRetries.forEach { host?.removeCallbacks(it) } + pendingRetries.clear() + } + + private fun logFailureOnce(error: Throwable) { + if (failed) return + failed = true + Log.w(TAG, "Could not update the OneUI AOD battery indication", error) + } + + private companion object { val RETRY_DELAYS = longArrayOf(96L, 224L, 480L) } +} + +private data class BatteryState(val level: Int, val plugged: Boolean, val charged: Boolean) { + fun formattedPercent(): CharSequence = NumberFormat.getPercentInstance().format(level / 100.0) +} + +@Suppress("DEPRECATION") +private fun currentBatteryState(context: Context): BatteryState? { + val app = context.applicationContext + val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + app.registerReceiver(null, filter, Context.RECEIVER_EXPORTED) + } else { + app.registerReceiver(null, filter) + } ?: return null + val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100) + if (level < 0 || scale <= 0) return null + val percent = Math.round((level * 100f) / scale).coerceIn(0, 100) + val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) + val plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0 || + status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL + return BatteryState(percent, plugged, status == BatteryManager.BATTERY_STATUS_FULL || percent >= 100) +} + +private fun currentApplicationContext(): Context? = runCatching { + Class.forName("android.app.ActivityThread").getMethod("currentApplication").invoke(null) as? Context +}.getOrNull() + +private fun ClassLoader.findClass(name: String) = runCatching { Class.forName(name, false, this) }.getOrNull() + +private fun Class<*>.methodsNamed(name: String): List = declaredMethods.filter { it.name == name }.onEach { + it.isAccessible = true +} + +private fun Class<*>.staticField(name: String): Any? = findField(name)?.get(null) + +private fun Any?.field(name: String): Any? = this?.javaClass?.findField(name)?.get(this) + +private fun Class<*>.findField(name: String): Field? { + var type: Class<*>? = this + while (type != null) { + runCatching { type.getDeclaredField(name) }.getOrNull()?.let { + it.isAccessible = true + return it + } + type = type.superclass + } + return null +} + +private fun Any?.invokeNamed(name: String, vararg args: Any?): Any? { + val target = this ?: return null + var type: Class<*>? = target.javaClass + while (type != null) { + type.declaredMethods.firstOrNull { method -> + method.name == name && method.parameterCount == args.size && + method.parameterTypes.indices.all { method.parameterTypes[it].accepts(args[it]) } + }?.let { method -> + method.isAccessible = true + return method.invoke(target, *args) + } + type = type.superclass + } + error("${target.javaClass.name}.$name(${args.size}) is unavailable") +} + +private fun Class<*>.accepts(value: Any?): Boolean { + if (value == null) return !isPrimitive + val boxed = when (this) { + Boolean::class.javaPrimitiveType -> Boolean::class.java + Int::class.javaPrimitiveType -> Int::class.java + Long::class.javaPrimitiveType -> Long::class.java + Float::class.javaPrimitiveType -> Float::class.java + Double::class.javaPrimitiveType -> Double::class.java + else -> this + } + return boxed.isInstance(value) +} + +private fun XposedInterface.intercept(method: Method, callback: (XposedInterface.Chain) -> Any?) { + hook(method).intercept(callback) +} + +private const val TAG = "NotificationsMaster" diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt index a828ae5..4174036 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt @@ -35,6 +35,7 @@ internal object ProcessVisibilityPolicyCache { val unlockedIconLimit get() = policy.unlockedIconLimit val lockscreenCards get() = policy.lockscreenCards val aodCards get() = policy.aodCards + val systemUiMisc get() = policy.systemUiMisc private fun tryInstall() { if (installed) return diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt index cabc630..814eb41 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt @@ -40,6 +40,12 @@ data class AppVisibilityPolicy( init { require(packageName.isNotBlank()) } } +data class SystemUiMiscSettings( + val keepAodVisibleDuringCalls: Boolean = false, + val showLockscreenBatteryPercentWhenUnplugged: Boolean = true, + val showAodBatteryPercentWhenUnplugged: Boolean = true, +) + data class VisibilityPolicy( val generation: Long = 0, val enabled: Boolean = true, @@ -47,6 +53,7 @@ data class VisibilityPolicy( val unlockedIconLimit: Int? = null, val lockscreenCards: CardsGridSettings = CardsGridSettings.LOCKSCREEN_DEFAULT, val aodCards: CardsGridSettings = CardsGridSettings.AOD_DEFAULT, + val systemUiMisc: SystemUiMiscSettings = SystemUiMiscSettings(), ) { init { require(generation >= 0) @@ -74,6 +81,7 @@ class CompiledVisibilityPolicy(policy: VisibilityPolicy) { val unlockedIconLimit = policy.unlockedIconLimit val lockscreenCards = policy.lockscreenCards val aodCards = policy.aodCards + val systemUiMisc = policy.systemUiMisc private val enabled = policy.enabled private val apps = policy.apps.associate { app -> app.packageName to CompiledAppPolicy( diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJson.kt b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJson.kt index 52f5700..a929134 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJson.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJson.kt @@ -13,6 +13,7 @@ internal object VisibilityPolicyJson { .put("unlockedIconLimit", policy.unlockedIconLimit ?: JSONObject.NULL) .put("lockscreenCards", policy.lockscreenCards.toJson()) .put("aodCards", policy.aodCards.toJson()) + .put("systemUiMisc", policy.systemUiMisc.toJson()) .put("apps", JSONArray().apply { policy.apps.forEach { put(it.toJson()) } }) .toString().encodeToByteArray() @@ -28,9 +29,22 @@ internal object VisibilityPolicyJson { ?: CardsGridSettings.LOCKSCREEN_DEFAULT, aodCards = root.optJSONObject("aodCards")?.toCardsGridSettings() ?: CardsGridSettings.AOD_DEFAULT, + systemUiMisc = root.optJSONObject("systemUiMisc")?.toSystemUiMiscSettings() + ?: SystemUiMiscSettings(), ) } + private fun SystemUiMiscSettings.toJson() = JSONObject() + .put("keepAodVisibleDuringCalls", keepAodVisibleDuringCalls) + .put("showLockscreenBatteryPercentWhenUnplugged", showLockscreenBatteryPercentWhenUnplugged) + .put("showAodBatteryPercentWhenUnplugged", showAodBatteryPercentWhenUnplugged) + + private fun JSONObject.toSystemUiMiscSettings() = SystemUiMiscSettings( + keepAodVisibleDuringCalls = getBoolean("keepAodVisibleDuringCalls"), + showLockscreenBatteryPercentWhenUnplugged = getBoolean("showLockscreenBatteryPercentWhenUnplugged"), + showAodBatteryPercentWhenUnplugged = getBoolean("showAodBatteryPercentWhenUnplugged"), + ) + private fun CardsGridSettings.toJson() = JSONObject() .put("maxRows", maxRows) .put("maxIconsPerRow", maxIconsPerRow) diff --git a/app/src/main/res/layout/fragment_miscellaneous.xml b/app/src/main/res/layout/fragment_miscellaneous.xml new file mode 100644 index 0000000..1a39ca9 --- /dev/null +++ b/app/src/main/res/layout/fragment_miscellaneous.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/drawer_menu.xml b/app/src/main/res/menu/drawer_menu.xml index 375c99b..10eae17 100644 --- a/app/src/main/res/menu/drawer_menu.xml +++ b/app/src/main/res/menu/drawer_menu.xml @@ -32,6 +32,9 @@ + Notifications Debug icons Cards layout + Miscellaneous Filter apps Log display Filter logging @@ -31,6 +32,13 @@     Apps     Cards layout     Debug icons +     Miscellaneous     Filter logging     Filter apps + Keep AOD visible during calls + Prevents OneUI from hiding the always-on display while a call is active. + Show battery % on lockscreen when unplugged + Shows the battery percentage even when the phone is not charging. + Show battery % on AOD when unplugged + Shows the battery percentage in the bottom AOD area even when the phone is not charging. diff --git a/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJsonTest.kt b/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJsonTest.kt index e1b6ebb..5210b3e 100644 --- a/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJsonTest.kt +++ b/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJsonTest.kt @@ -11,6 +11,7 @@ class VisibilityPolicyJsonTest { unlockedIconLimit = 7, lockscreenCards = CardsGridSettings(2, 7, false, false, 72), aodCards = CardsGridSettings(3, 6, true, true, 48), + systemUiMisc = SystemUiMiscSettings(true, false, true), apps = listOf(AppVisibilityPolicy( packageName = "example.app", blockedSurfaces = setOf(NotificationSurface.AOD), @@ -37,6 +38,7 @@ class VisibilityPolicyJsonTest { assertEquals(CardsGridSettings.LOCKSCREEN_DEFAULT, decoded.lockscreenCards) assertEquals(CardsGridSettings.AOD_DEFAULT, decoded.aodCards) + assertEquals(SystemUiMiscSettings(), decoded.systemUiMisc) } @Test(expected = IllegalArgumentException::class)