72 Commits
Author SHA1 Message Date
ajp_anton 32d0935389 Add notification drawer visibility controls 2026-09-14 10:02:08 +00:00
ajp_anton eef433ce88 Trim SystemUI rendering infrastructure 2026-09-03 00:55:12 +00:00
ajp_anton 1d2144e72c Streamline encrypted settings and logging 2026-09-03 00:52:31 +00:00
ajp_anton 78bf4bde8c Remove obsolete UI scaffolding 2026-09-03 00:49:14 +00:00
ajp_anton 99f999d4e0 Fix visibility exception field spacing 2026-09-03 00:30:37 +00:00
ajp_anton ed5f0c67db Separate OneUI lockscreen and AOD filtering 2026-09-03 00:30:37 +00:00
ajp_anton b1458b13e3 Filter AOSP lockscreen notifications 2026-08-31 03:24:33 +00:00
ajp_anton eafc544da8 Disable OneUI-only settings on other devices 2026-08-31 02:39:02 +00:00
ajp_anton dc2d50868f Restore stock cards when custom rendering stops 2026-08-31 01:27:05 +00:00
ajp_anton 3289d62ca0 Consolidate notification utility settings 2026-08-31 01:19:58 +00:00
ajp_anton dc11e605d1 Redesign notification visibility controls 2026-08-31 01:18:40 +00:00
ajp_anton a7c9a2a22f Improve alert setup and access guidance 2026-08-31 01:14:34 +00:00
ajp_anton 0a8405e171 Update the Notifications Master app icon 2026-08-31 01:11:18 +00:00
ajp_anton 30753f1b6a Fix anchored notification visibility exceptions 2026-08-30 23:43:09 +00:00
ajp_anton 08f8764c77 Improve Cards layout number controls 2026-08-30 23:41:58 +00:00
ajp_anton 55de8f7aa9 fix: refresh OneUI lockscreen battery indication 2026-08-30 23:31:30 +00:00
ajp_anton fe99f789ed fix: remove OneUI unlocked icon cap 2026-08-30 23:31:24 +00:00
ajp_anton 2bf35884dc fix: stabilize OneUI AOD notification grids 2026-08-30 23:31:16 +00:00
ajp_anton 4ff37e6699 Port OneUI miscellaneous display settings 2026-08-30 12:40:31 +00:00
ajp_anton 192bdb5381 Own OneUI Cards shelves during transitions 2026-08-30 12:11:35 +00:00
ajp_anton 0a0e7c7649 Stabilize OneUI Cards mode transitions 2026-08-30 01:42:23 +00:00
ajp_anton 7331ac8f01 Reorganize notification visibility settings 2026-08-30 01:40:16 +00:00
ajp_anton e814a1aab3 Keep debug notifications independent 2026-08-30 01:40:05 +00:00
ajp_anton 738ae25f1d Add built-in debug notification generator 2026-08-29 23:54:58 +00:00
ajp_anton ce4adef117 Fix OneUI AOD Cards grid rendering 2026-08-29 23:47:25 +00:00
ajp_anton b7d6bb6d63 Render OneUI AOD Cards notification grid 2026-08-29 23:22:11 +00:00
ajp_anton 626776886a Test Cards grid rendering lifecycle 2026-08-29 23:21:05 +00:00
ajp_anton 179dc3a1db Fix Cards layout control IDs 2026-08-29 23:14:02 +00:00
ajp_anton c8d7e5005e Document notification visibility integration 2026-08-29 23:11:38 +00:00
ajp_anton 568b9a9549 Render OneUI lockscreen Cards overflow grid 2026-08-29 23:08:59 +00:00
ajp_anton ae1bf8e62b Add OneUI cards layout settings 2026-08-29 22:26:25 +00:00
ajp_anton c14b2c5e61 Fix OneUI lockscreen card overflow filtering 2026-08-29 20:40:02 +00:00
ajp_anton c831e9adfb Filter OneUI lockscreen notification cards 2026-08-29 20:13:28 +00:00
ajp_anton cab90de5af Filter native lockscreen and AOD surfaces 2026-08-29 13:40:54 +00:00
ajp_anton 47fbda8a5e Add notification visibility settings 2026-08-29 13:21:07 +00:00
ajp_anton 4fcbffd4a5 Support unlocked icon filtering across Android variants 2026-08-29 13:08:40 +00:00
ajp_anton 38a099f5d3 Filter AOSP 36 unlocked notification icons 2026-08-29 12:36:21 +00:00
ajp_anton a3063d0c9a Add notification visibility policy bridge 2026-08-29 12:19:54 +00:00
ajp_anton 44357bf047 Improve alert profile and rule editors 2026-08-19 21:19:27 +00:00
ajp_anton de984fb36c Limit custom alerts to notification events 2026-08-19 20:57:17 +00:00
ajp_anton e80486df65 Clarify direct alert integration 2026-08-19 19:44:43 +00:00
ajp_anton b06434b7a0 Fix direct alert delivery across users 2026-08-19 19:38:36 +00:00
ajp_anton e2fb9e0d1c Document optional custom alerts 2026-08-18 16:36:13 +00:00
ajp_anton 4a7981669b Keep listener active for direct alert control 2026-08-18 16:35:31 +00:00
ajp_anton 4e0c5d5298 Limit app lock to notification logs 2026-08-18 16:34:35 +00:00
ajp_anton a17d5651c8 Control direct notification vibrations 2026-08-18 16:32:31 +00:00
ajp_anton 8b66456d8c Expose alert rule outcomes 2026-08-18 16:19:29 +00:00
ajp_anton bd727eaec0 Respect Do Not Disturb for custom alerts 2026-08-18 16:16:47 +00:00
ajp_anton c9d189ac97 Handle non-public notification records 2026-08-18 16:01:05 +00:00
ajp_anton 243bf62e67 Support native alert suppression on Android 14 and 15 2026-08-18 11:34:34 +00:00
ajp_anton dbe6335978 Harden LSPosed bridge startup 2026-08-18 06:01:43 +00:00
ajp_anton 68771d5836 Suppress matched native notification alerts 2026-08-18 05:25:48 +00:00
ajp_anton 7166cfe18b Add privileged alert policy endpoint 2026-08-18 03:22:03 +00:00
ajp_anton 77a725ae30 Explain rootless alert status 2026-08-18 02:03:15 +00:00
ajp_anton 7fac006ad3 Allow alert rules to cover multiple apps 2026-08-18 01:55:32 +00:00
ajp_anton ba09bed24f Allow alert rules to be removed 2026-08-18 01:54:22 +00:00
ajp_anton 7f61f11cbf Add per-app multiple match option 2026-08-18 01:53:19 +00:00
ajp_anton ce49f21739 Add per-app alert rule management 2026-08-18 01:52:31 +00:00
ajp_anton 2f546afc89 Add custom alert rules 2026-08-18 01:46:22 +00:00
ajp_anton 40a9a1b042 Add custom alert profiles 2026-08-18 01:37:28 +00:00
ajp_anton 1b21ff6751 Add alert queue settings page 2026-08-18 01:35:07 +00:00
ajp_anton 6aab879dbd Route notification alerts through playback 2026-08-18 01:32:56 +00:00
ajp_anton 0089f839e3 Add Android alert effect playback 2026-08-16 17:21:51 +00:00
ajp_anton de09b283ad Add alert playback controller 2026-08-16 17:19:52 +00:00
ajp_anton 594931e51d Add consolidated alert rule editing 2026-08-16 17:18:43 +00:00
ajp_anton c209ef24bb Add custom alert queue controller 2026-08-16 17:17:39 +00:00
ajp_anton 720db4b7d3 Persist encrypted alert configuration 2026-08-16 17:16:07 +00:00
ajp_anton ae409612b4 Add guarded notification bridge 2026-08-16 17:11:15 +00:00
ajp_anton 5e0662a233 Add custom alert rule engine 2026-08-16 03:59:32 +00:00
ajp_anton 9c9ddb6350 Add fail-open LSPosed notification probe 2026-08-16 03:55:44 +00:00
ajp_anton af3673c2e8 Use Notifications Master app ID 2026-08-16 01:57:44 +00:00
ajp_anton 572660a967 Rename app to Notifications Master 2026-08-16 01:49:58 +00:00
159 changed files with 8978 additions and 391 deletions
+53 -8
View File
@@ -1,8 +1,8 @@
# Notification Log
# Notifications Master
Notification Log keeps a private history of the notifications that appear on
your Android device. It works as a standard Android notification listener; it
does not need root or an Xposed module.
Notifications Master keeps a private history of your Android notifications and
can play custom alerts for the ones that matter. It works normally without root
or an Xposed module.
## AI Assistance
@@ -30,14 +30,59 @@ unencrypted file at the location you choose. Android may withhold sensitive or
custom notification content; this app records only what Android makes available
to a notification listener.
When all logging types are disabled, the notification listener is disabled too:
the app has no polling, scheduled job, or background service to run.
The optional lock protects log screens with your device biometric or credential
prompt; the alert setup remains usable. When all logging types, custom alert
rules, and direct-alert controls are disabled, the notification listener is
disabled too: the app has no polling or scheduled job to run.
## Custom alerts
Create named sound and vibration profiles, then assign them to per-app rules.
Rules can match notification text, respond to new notifications or meaningful
edits, and either play a profile, silence the original alert, or leave it
unchanged. Rules can be ordered, combined, and queued; a protected alert can
play to completion.
Without optional system integration, a matching profile plays alongside the
source app's normal alert. With a compatible LSPosed setup enabled, Notifications
Master can suppress a matched standard notification alert while leaving the
notification itself visible. It only evaluates actual posted or updated
notifications; standalone sounds and vibrations remain under the source app's
control.
To use the optional integration, enable Notifications Master as an LSPosed
module and give it the **Android/system server** scope, then restart Android.
Custom alerts respect Do Not Disturb. Android does not allow a normal app to
force direct custom sound or vibration through Do Not Disturb.
## Notification visibility
The optional LSPosed integration can hide an app's notifications independently
from the unlocked status bar, collapsed lockscreen, always-on display, and
notification drawer. The drawer choice is shared between its locked and
unlocked forms. Text exceptions can override an app's default for individual
notifications; hiding a drawer card does not dismiss the notification.
On OneUI, Notifications Master can also lay out the extra notification icons
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.
## Requirements
Notification Log supports Android 14 through Android 16 (API levels 3436).
Notifications Master supports Android 14 through Android 16 (API levels 3436).
After installation, grant **Notification access** when prompted. The optional
app lock uses your device's biometric or credential prompt.
log lock uses your device's biometric or credential prompt. LSPosed integration
is optional and requires a compatible rooted device plus a system-server scope;
the notification log and ordinary custom alerts remain available without it.
Tested Android versions:
+11 -4
View File
@@ -34,15 +34,15 @@ if (releaseBuildRequested && !hasReleaseSigning) {
}
android {
namespace = "se.ajpanton.notificationlog"
namespace = "se.ajpanton.notificationsmaster"
compileSdk = 36
defaultConfig {
applicationId = "se.ajpanton.notificationlog"
applicationId = "se.ajpanton.notificationsmaster"
minSdk = 34
targetSdk = 36
versionCode = project.findProperty("notificationLogVersionCode")?.toString()?.toIntOrNull() ?: 1
versionName = project.findProperty("notificationLogVersionName")?.toString() ?: "0.1"
versionCode = project.findProperty("notificationsMasterVersionCode")?.toString()?.toIntOrNull() ?: 1
versionName = project.findProperty("notificationsMasterVersionName")?.toString() ?: "0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -91,6 +91,13 @@ dependencies {
implementation(libs.recyclerview)
implementation(libs.swiperefreshlayout)
implementation(libs.lifecycle.runtime.ktx)
implementation(libs.re2j)
// Present only when the user enables this APK as an LSPosed module.
compileOnly(libs.libxposed.api)
testImplementation(libs.junit)
testImplementation("org.json:json:20240303")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test:runner:1.6.2")
}
@@ -0,0 +1,43 @@
package se.ajpanton.notificationsmaster.alerts
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class AlertConfigurationStoreTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val configurationFile = File(context.filesDir, "alert-configuration.bin")
@After
fun removeStoredConfiguration() {
configurationFile.delete()
File(configurationFile.path + ".bak").delete()
File(configurationFile.path + ".new").delete()
}
@Test
fun encryptsAndReloadsConfiguration() {
val profile = AlertProfile("profile", "Sensitive profile", vibrationPattern = listOf(0, 120))
val configuration = AlertConfiguration(
profiles = listOf(profile),
appSettings = listOf(AlertAppSettings("chat.app")),
rules = listOf(
AlertRule(
"rule", "chat.app", 0, setOf(AlertSource.NOTIFICATION_POST),
outcome = AlertOutcome.PLAY_PROFILE, profileId = profile.id,
),
),
)
AlertConfigurationStore(context).save(configuration)
assertEquals(configuration, AlertConfigurationStore(context).load())
assertFalse(configurationFile.readBytes().decodeToString().contains(profile.name))
}
}
@@ -0,0 +1,59 @@
package se.ajpanton.notificationsmaster.alerts
import android.content.ComponentName
import android.content.pm.PackageManager
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Before
import org.junit.Assert.assertEquals
import org.junit.Test
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
import se.ajpanton.notificationsmaster.settings.LoggingRule
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
import se.ajpanton.notificationsmaster.settings.LoggingType
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicy
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
class AlertListenerPolicyDeviceTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val component = ComponentName(context, NotificationCaptureService::class.java)
@Before fun clearVisibilityRules() {
VisibilityPolicyStore(context).save(VisibilityPolicy())
}
@After
fun restoreDefaults() {
LoggingType.entries.forEach { LoggingRuleStore(context).save(it, LoggingRule()) }
AlertConfigurationStore(context).save(AlertConfiguration())
VisibilityPolicyStore(context).save(VisibilityPolicy())
}
@Test
fun alertRuleKeepsListenerEnabledWithoutLogging() {
LoggingType.entries.forEach { LoggingRuleStore(context).save(it, LoggingRule(enabled = false)) }
val profile = AlertProfile("profile", "Profile", vibrationPattern = listOf(0, 10))
val configuration = AlertConfiguration(
profiles = listOf(profile),
rules = listOf(
AlertRule(
"rule", "example.app", 0, setOf(AlertSource.NOTIFICATION_POST),
outcome = AlertOutcome.PLAY_PROFILE, profileId = profile.id,
),
),
)
AlertConfigurationStore(context).save(configuration)
assertEquals(
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
context.packageManager.getComponentEnabledSetting(component),
)
AlertConfigurationStore(context).save(AlertConfiguration())
assertEquals(
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
context.packageManager.getComponentEnabledSetting(component),
)
}
}
@@ -0,0 +1,40 @@
package se.ajpanton.notificationsmaster.alerts
import android.app.NotificationManager
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class AndroidAlertEffectPlayerTest {
@Test
fun vibrationCompletesWithoutLeakingPlayback() {
val completion = CountDownLatch(1)
val player = AndroidAlertEffectPlayer(InstrumentationRegistry.getInstrumentation().targetContext)
player.play(
QueuedAlert("test", AlertSnapshot(null, listOf(0, 10), false, false)),
completion::countDown,
)
assertTrue(completion.await(2, TimeUnit.SECONDS))
player.stop()
}
@Test
fun dndSkipsPlaybackAndCompletesImmediately() {
val completion = CountDownLatch(1)
val player = AndroidAlertEffectPlayer(
InstrumentationRegistry.getInstrumentation().targetContext,
) { NotificationManager.INTERRUPTION_FILTER_NONE }
player.play(
QueuedAlert("test", AlertSnapshot(null, listOf(0, 1_000), false, false)),
completion::countDown,
)
assertTrue(completion.await(100, TimeUnit.MILLISECONDS))
player.stop()
}
}
@@ -0,0 +1,45 @@
package se.ajpanton.notificationsmaster.module
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import se.ajpanton.notificationsmaster.alerts.AlertConfiguration
import se.ajpanton.notificationsmaster.alerts.AlertAppSettings
import se.ajpanton.notificationsmaster.alerts.AlertOutcome
import se.ajpanton.notificationsmaster.alerts.AlertProfile
import se.ajpanton.notificationsmaster.alerts.AlertRule
import se.ajpanton.notificationsmaster.alerts.AlertSource
import se.ajpanton.notificationsmaster.alerts.AlertTextMatcher
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
/** Installs a predictable helper policy for the AOSP LSPosed smoke test. */
@RunWith(AndroidJUnit4::class)
class AlertPolicySyncDeviceTest {
@Test
fun savesMatchingHelperPolicy() {
val profile = AlertProfile("module-test", "Module test", vibrationPattern = listOf(0, 200))
val configuration = AlertConfiguration(
profiles = listOf(profile),
rules = listOf(
AlertRule(
id = "module-helper-post",
packageName = HELPER_PACKAGE,
order = 0,
sources = setOf(AlertSource.NOTIFICATION_POST),
matcher = AlertTextMatcher(),
outcome = AlertOutcome.PLAY_PROFILE,
profileId = profile.id,
),
),
)
val store = AlertConfigurationStore(InstrumentationRegistry.getInstrumentation().targetContext)
store.save(configuration)
assertEquals(configuration, store.load())
}
private companion object {
const val HELPER_PACKAGE = "se.ajpanton.notificationsmaster.helper"
}
}
@@ -0,0 +1,67 @@
package se.ajpanton.notificationsmaster.module
import android.content.Intent
import android.graphics.drawable.Icon
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import se.ajpanton.notificationsmaster.visibility.CardsGridSettings
@RunWith(AndroidJUnit4::class)
class OneUiCardsGridRendererTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
@Test fun hidesStockOnlyAfterRenderingAndRestoresIt() {
val activity = instrumentation.startActivitySync(
Intent(instrumentation.targetContext, GridTestActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
val renderer = OneUiCardsGridRenderer()
lateinit var root: ViewGroup
lateinit var source: FrameLayout
instrumentation.runOnMainSync {
root = activity.window.decorView as ViewGroup
source = FrameLayout(activity)
root.addView(source, ViewGroup.LayoutParams(300, 80))
renderer.updateSources(source, listOf(OneUiCardsGridRenderer.Source(
"test-key",
view = null,
Icon.createWithResource(activity, android.R.drawable.ic_dialog_info),
)))
renderer.refresh(source, active = true, emptySet(), CONFIG)
}
instrumentation.waitForIdleSync()
instrumentation.runOnMainSync {
assertEquals(0f, source.alpha)
val host = root.findViewWithTag<ViewGroup>("notifications-master:cards-grid")
assertEquals(View.VISIBLE, host.visibility)
assertEquals(1, host.childCount)
renderer.refresh(source, active = false, emptySet(), CONFIG)
}
instrumentation.waitForIdleSync()
instrumentation.runOnMainSync {
assertEquals(1f, source.alpha)
renderer.release(source)
assertNull(root.findViewWithTag<View>("notifications-master:cards-grid"))
root.removeView(source)
activity.finish()
}
}
private companion object {
val CONFIG = OneUiCardsGridRenderer.Config(
CardsGridSettings(1, 5, true, true, 60),
showPills = false,
hideShelf = false,
)
}
}
@@ -0,0 +1,41 @@
package se.ajpanton.notificationsmaster.visibility
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Explicitly invoked module-test fixtures; normal connected tests skip them. */
@RunWith(AndroidJUnit4::class)
class VisibilityModuleFixtureTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val store = VisibilityPolicyStore(instrumentation.targetContext)
@Test fun blockHelperExceptEditedText() {
requireFixtureMode()
store.save(VisibilityPolicy(apps = listOf(AppVisibilityPolicy(
packageName = HELPER_PACKAGE,
blockedSurfaces = NotificationSurface.entries.toSet(),
exceptions = listOf(VisibilityExceptionRule("Edited message", emptySet())),
))))
}
@Test fun blockHelperInDrawerOnly() {
requireFixtureMode()
store.save(VisibilityPolicy(apps = listOf(
AppVisibilityPolicy(HELPER_PACKAGE, setOf(NotificationSurface.DRAWER)),
)))
}
@Test fun allowHelper() {
requireFixtureMode()
store.save(VisibilityPolicy())
}
private fun requireFixtureMode() {
assumeTrue(InstrumentationRegistry.getArguments().getString("visibilityFixture") == "true")
}
private companion object { const val HELPER_PACKAGE = "se.ajpanton.notificationsmaster.helper" }
}
@@ -0,0 +1,54 @@
package se.ajpanton.notificationsmaster.visibility
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Before
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class VisibilityPolicyStoreTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
@Before @After fun cleanUp() {
File(context.filesDir, "visibility-policy.bin").delete()
File(context.filesDir, "visibility-policy.bin.bak").delete()
}
@Test fun encryptedPolicyRoundTripsAndAdvancesGeneration() {
val store = VisibilityPolicyStore(context)
val original = VisibilityPolicy(apps = listOf(
AppVisibilityPolicy("example.app", setOf(NotificationSurface.UNLOCKED_STATUSBAR)),
))
val first = store.save(original)
val second = store.save(first.copy(enabled = false))
assertEquals(1, first.generation)
assertEquals(2, second.generation)
assertEquals(second, store.load())
val raw = File(context.filesDir, "visibility-policy.bin").readBytes().decodeToString()
assertFalse(raw.contains("example.app"))
assertTrue(File(context.filesDir, "visibility-policy.bin").length() > 0)
}
@Test fun updateAndPackageCleanupKeepOnlyInstalledPolicies() {
val store = VisibilityPolicyStore(context)
store.update { policy ->
policy.copy(apps = listOf(
AppVisibilityPolicy("installed.app", setOf(NotificationSurface.AOD)),
AppVisibilityPolicy("removed.app", setOf(NotificationSurface.UNLOCKED_STATUSBAR)),
))
}
store.removeUninstalledPackages(setOf("installed.app"))
assertEquals(listOf("installed.app"), store.load().apps.map { it.packageName })
assertEquals(2, store.load().generation)
}
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="se.ajpanton.notificationsmaster.module.GridTestActivity"
android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,12 @@
package se.ajpanton.notificationsmaster.module
import android.app.Activity
import android.os.Bundle
import android.widget.FrameLayout
class GridTestActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(FrameLayout(this))
}
}
+26 -3
View File
@@ -2,6 +2,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY" />
<permission
android:name="se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY"
android:protectionLevel="signature" />
<!-- Required for the all-installed-apps filter; this app is not distributed through Google Play. -->
<uses-permission
@@ -9,14 +17,15 @@
tools:ignore="QueryAllPackagesPermission" />
<application
android:name=".NotificationLogApplication"
android:name=".NotificationsMasterApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.NotificationLog">
android:theme="@style/Theme.NotificationsMaster">
<activity
android:name=".MainActivity"
@@ -36,6 +45,20 @@
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<receiver
android:name=".module.AlertPolicyBootReceiver"
android:enabled="false"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name=".module.VisibilityPolicyRequestReceiver"
android:exported="true" />
</application>
</manifest>
@@ -1,34 +0,0 @@
package se.ajpanton.notificationlog
import android.os.Bundle
import android.view.View
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import se.ajpanton.notificationlog.databinding.FragmentPageBinding
class PageFragment : Fragment(R.layout.fragment_page) {
private var binding: FragmentPageBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentPageBinding.bind(view)
binding?.pageTitle?.setText(requireArguments().getInt(ARG_TITLE))
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
fun setPageTitleVisible(visible: Boolean) {
binding?.pageTitle?.visibility = if (visible) View.VISIBLE else View.GONE
}
companion object {
private const val ARG_TITLE = "title"
fun newInstance(@StringRes titleRes: Int) = PageFragment().apply {
arguments = Bundle().apply { putInt(ARG_TITLE, titleRes) }
}
}
}
@@ -1,10 +0,0 @@
package se.ajpanton.notificationlog.capture
import java.util.concurrent.ConcurrentHashMap
/** Process/boot-lifetime record of packages seen by the listener. */
object SeenApps {
private val packages = ConcurrentHashMap.newKeySet<String>()
fun markSeen(packageName: String) { packages += packageName }
fun snapshot(): Set<String> = packages.toSet()
}
@@ -1,41 +0,0 @@
package se.ajpanton.notificationlog.settings
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import android.service.notification.NotificationListenerService
import android.util.Log
import se.ajpanton.notificationlog.capture.NotificationCaptureService
/**
* Notification listeners are re-bound by Android after boot when their component
* is enabled and the user granted notification access. Disabling the component
* is therefore both the no-work battery mode and the no-start-on-boot mode.
*/
internal object NotificationListenerComponentController {
fun update(context: Context, rules: Map<LoggingType, LoggingRule>, hasEnabledEventOverride: Boolean = false) {
val applicationContext = context.applicationContext
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
val packageManager = applicationContext.packageManager
val desired = if (NotificationListenerPolicy.shouldRun(rules, hasEnabledEventOverride)) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
if (packageManager.getComponentEnabledSetting(component) != desired) {
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
}
if (desired == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
try {
// Data clearing and OEM listener management can leave an enabled
// component without a live binding. Ask Android to reconnect on
// every enabled-rule synchronization, not just a state change.
NotificationListenerService.requestRebind(component)
} catch (error: SecurityException) {
Log.w(TAG, "Notification access is not granted yet; Android will bind after the user grants it", error)
}
}
}
private const val TAG = "NotificationListenerControl"
}
@@ -0,0 +1,182 @@
package se.ajpanton.notificationsmaster
import android.content.Context
import android.view.View
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.ScrollView
import android.widget.TextView
import androidx.core.widget.doAfterTextChanged
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationsmaster.alerts.AlertConfiguration
import se.ajpanton.notificationsmaster.alerts.AlertOutcome
import se.ajpanton.notificationsmaster.alerts.AlertRule
import se.ajpanton.notificationsmaster.alerts.AlertRuleDefinition
import se.ajpanton.notificationsmaster.alerts.AlertSource
import se.ajpanton.notificationsmaster.alerts.AlertTextField
import se.ajpanton.notificationsmaster.alerts.AlertTextMatcher
import se.ajpanton.notificationsmaster.alerts.AlertTextMode
object AlertRuleDialog {
fun show(
context: Context,
configuration: AlertConfiguration,
existing: AlertRule?,
apps: List<InstalledApp>? = null,
onSave: (List<String>?, AlertRuleDefinition) -> Unit,
onDelete: (() -> Unit)? = null,
) {
val form = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL; setPadding(dp(context, 24), 0, dp(context, 24), 0) }
val selectedApps = linkedSetOf<String>()
val app = apps?.let { choices ->
Button(context).apply {
text = "Choose apps"
setOnClickListener { showAppChooser(context, choices, selectedApps, this) }
}.also(form::addView)
}
val profiles = configuration.profiles
val profile = Spinner(context).apply {
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, profiles.map { it.name })
existing?.let { rule -> setSelection(profiles.indexOfFirst { it.id == rule.profileId }.coerceAtLeast(0)) }
}
val outcomes = listOf(
AlertOutcome.PLAY_PROFILE to "Play profile",
AlertOutcome.SILENCE_ORIGINAL to "Silence original alert",
AlertOutcome.PASS_THROUGH to "Keep original alert",
)
val outcome = Spinner(context).apply {
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, outcomes.map { it.second })
setSelection(outcomes.indexOfFirst { it.first == existing?.outcome }.coerceAtLeast(0))
}
val post = CheckBox(context).apply { text = "Appearing"; isChecked = existing == null || AlertSource.NOTIFICATION_POST in existing.sources }
val update = CheckBox(context).apply { text = "Edits"; isChecked = existing == null || AlertSource.NOTIFICATION_UPDATE in existing.sources }
val pattern = EditText(context).apply { hint = "Text to match (optional)"; setText(existing?.matcher?.value) }
val regex = SwitchMaterial(context).apply { text = "Use regular expression"; isChecked = existing?.matcher?.mode == AlertTextMode.REGEX }
val protected = SwitchMaterial(context).apply { text = "Play to completion"; isChecked = existing?.playToCompletion == true }
val dnd = SwitchMaterial(context).apply {
text = "Allow during DND (requires system support)"
isChecked = existing?.allowDuringDnd == true
}
listOf(outcome, profile, post, update, pattern, regex, protected, dnd).forEach(form::addView)
form.addView(android.widget.TextView(context).apply {
text = "Android blocks direct custom alerts during Do Not Disturb. This setting is retained for optional system support, but does not bypass Do Not Disturb in the normal app."
textSize = 14f
})
fun updateOutcomeFields() {
val playsProfile = outcomes[outcome.selectedItemPosition].first == AlertOutcome.PLAY_PROFILE
profile.isEnabled = playsProfile
protected.isEnabled = playsProfile
dnd.isEnabled = playsProfile
}
outcome.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) = updateOutcomeFields()
}
updateOutcomeFields()
val dialog = MaterialAlertDialogBuilder(context).setTitle(if (existing == null) "Add rule" else "Edit rule")
.setView(form).setNegativeButton("Cancel", null).setPositiveButton("Save", null)
.apply { if (onDelete != null) setNeutralButton("Delete", null) }.show()
dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).setOnClickListener {
val sources = buildSet {
if (post.isChecked) add(AlertSource.NOTIFICATION_POST)
if (update.isChecked) add(AlertSource.NOTIFICATION_UPDATE)
}
if (sources.isEmpty()) { post.error = "Choose an event"; return@setOnClickListener }
if (apps != null && selectedApps.isEmpty()) { app?.error = "Choose at least one app"; return@setOnClickListener }
val value = pattern.text.toString().trim()
val matcher = runCatching {
AlertTextMatcher(
AlertTextField.ANY_TEXT,
if (value.isEmpty()) AlertTextMode.ANY else if (regex.isChecked) AlertTextMode.REGEX else AlertTextMode.CONTAINS,
value,
)
}.getOrElse {
pattern.error = "Invalid regular expression"
return@setOnClickListener
}
val selectedOutcome = outcomes[outcome.selectedItemPosition].first
val profileId = if (selectedOutcome == AlertOutcome.PLAY_PROFILE) {
profiles.getOrNull(profile.selectedItemPosition)?.id ?: run {
android.widget.Toast.makeText(context, "Create a profile first", android.widget.Toast.LENGTH_LONG).show()
return@setOnClickListener
}
} else null
val definition = AlertRuleDefinition(
sources,
matcher,
selectedOutcome,
profileId,
protected.isEnabled && protected.isChecked,
dnd.isEnabled && dnd.isChecked,
)
onSave(apps?.let { selectedApps.toList() }, definition)
dialog.dismiss()
}
if (onDelete != null) dialog.getButton(android.content.DialogInterface.BUTTON_NEUTRAL).setOnClickListener {
MaterialAlertDialogBuilder(context).setTitle("Delete rule?")
.setMessage("This cannot be undone.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Delete") { _, _ -> onDelete(); dialog.dismiss() }
.show()
}
}
private fun dp(context: Context, value: Int) = (value * context.resources.displayMetrics.density).toInt()
private fun showAppChooser(
context: Context,
apps: List<InstalledApp>,
selected: MutableSet<String>,
button: Button,
) {
val list = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
val search = EditText(context).apply { hint = "Filter apps" }
fun updateButton() {
button.text = when (selected.size) {
0 -> "Choose apps"
1 -> apps.first { it.packageName in selected }.name
else -> apps.first { it.packageName in selected }.name + ""
}
}
fun showMatches(query: String) {
val terms = query.lowercase().split(Regex("\\s+")).filter(String::isNotBlank)
list.removeAllViews()
apps.filter { app ->
val searchable = "${app.name} ${app.packageName}".lowercase()
terms.all(searchable::contains)
}.forEach { app ->
val checkBox = CheckBox(context).apply { isChecked = app.packageName in selected }
list.addView(LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
isClickable = true
setPadding(0, dp(context, 4), 0, dp(context, 4))
addView(checkBox)
addView(LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
addView(TextView(context).apply { text = app.name; textSize = 16f; setTypeface(typeface, android.graphics.Typeface.BOLD) })
addView(TextView(context).apply { text = app.packageName; textSize = 12f; alpha = 0.72f })
})
setOnClickListener { checkBox.isChecked = !checkBox.isChecked }
})
checkBox.setOnCheckedChangeListener { _, checked ->
if (checked) selected += app.packageName else selected -= app.packageName
}
}
}
search.doAfterTextChanged { showMatches(it.toString()) }
showMatches("")
val content = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(context, 24), 0, dp(context, 24), 0)
addView(search)
addView(ScrollView(context).apply { addView(list) }, LinearLayout.LayoutParams(-1, dp(context, 420)))
}
MaterialAlertDialogBuilder(context).setTitle("Apps this rule applies to")
.setView(content).setPositiveButton("Done") { _, _ -> updateButton() }.show()
}
}
@@ -0,0 +1,92 @@
package se.ajpanton.notificationsmaster
import android.app.NotificationManager
import android.content.ComponentName
import android.content.Intent
import android.content.res.ColorStateList
import android.os.Bundle
import android.provider.Settings
import android.view.View
import android.widget.ArrayAdapter
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.alerts.AlertQueueOverflow
import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
import se.ajpanton.notificationsmaster.databinding.FragmentAlertsBinding
class AlertsFragment : Fragment(R.layout.fragment_alerts) {
private var binding: FragmentAlertsBinding? = null
private lateinit var store: AlertConfigurationStore
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentAlertsBinding.bind(view)
store = AlertConfigurationStore(requireContext())
val configuration = store.load()
binding!!.enableAlerts.isChecked = configuration.enabled
binding!!.ignoreRoutineUpdates.isChecked = configuration.ignoreRoutineUpdates
binding!!.dropOldest.isChecked = configuration.queueSettings.overflow == AlertQueueOverflow.DROP_OLDEST
binding!!.queueLength.adapter = ArrayAdapter(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
(1..10).map(Int::toString),
)
binding!!.queueLength.setSelection(configuration.queueSettings.maximumLength - 1, false)
binding!!.notificationAccess.setOnClickListener {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
}
bindChanges()
}
override fun onResume() {
super.onResume()
val context = requireContext()
val access = context.getSystemService(NotificationManager::class.java)
.isNotificationListenerAccessGranted(ComponentName(context, NotificationCaptureService::class.java))
binding?.notificationAccess?.apply {
text = if (access) "Notification access enabled" else "Enable notification access"
backgroundTintList = ColorStateList.valueOf(
ContextCompat.getColor(
context,
if (access) R.color.notification_access_granted else R.color.notification_access_missing,
),
)
}
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
private fun bindChanges() {
binding!!.enableAlerts.setOnCheckedChangeListener { _, enabled ->
store.save(store.load().copy(enabled = enabled))
}
binding!!.ignoreRoutineUpdates.setOnCheckedChangeListener { _, ignore ->
store.save(store.load().copy(ignoreRoutineUpdates = ignore))
}
binding!!.dropOldest.setOnCheckedChangeListener { _, oldest ->
saveQueue(overflow = if (oldest) AlertQueueOverflow.DROP_OLDEST else AlertQueueOverflow.DROP_NEWEST)
}
binding!!.queueLength.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
saveQueue(maximumLength = position + 1)
}
}
}
private fun saveQueue(maximumLength: Int? = null, overflow: AlertQueueOverflow? = null) {
val current = store.load().queueSettings
store.save(
store.load().copy(
queueSettings = AlertQueueSettings(
maximumLength ?: current.maximumLength,
overflow ?: current.overflow,
),
),
)
}
}
@@ -0,0 +1,103 @@
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.alerts.AlertAppSettings
import se.ajpanton.notificationsmaster.alerts.AlertRule
import se.ajpanton.notificationsmaster.alerts.AlertRuleEditor
import se.ajpanton.notificationsmaster.databinding.FragmentAppRulesBinding
class AppRulesFragment : Fragment(R.layout.fragment_app_rules) {
private var binding: FragmentAppRulesBinding? = null
private lateinit var packageName: String
private lateinit var store: AlertConfigurationStore
private val adapter = RuleAdapter(::edit)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
packageName = requireArguments().getString(ARG_PACKAGE)!!
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentAppRulesBinding.bind(view)
store = AlertConfigurationStore(requireContext())
binding!!.rules.adapter = adapter
binding!!.addRule.setOnClickListener { edit(null) }
binding!!.allowMultipleMatches.isChecked = store.load().appSettings.firstOrNull { it.packageName == packageName }?.allowMultipleMatches == true
binding!!.allowMultipleMatches.setOnCheckedChangeListener { _, enabled -> setAllowMultipleMatches(enabled) }
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
override fun onMove(recyclerView: RecyclerView, holder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
adapter.move(holder.bindingAdapterPosition, target.bindingAdapterPosition)
saveOrder()
return true
}
override fun onSwiped(holder: RecyclerView.ViewHolder, direction: Int) = Unit
}).attachToRecyclerView(binding!!.rules)
refresh()
}
override fun onResume() {
super.onResume()
activity?.title = InstalledApps.name(requireContext(), packageName)
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun refresh() = adapter.submit(store.load().rules.filter { it.packageName == packageName }.sortedBy { it.order })
private fun edit(existing: AlertRule?) {
val configuration = store.load()
AlertRuleDialog.show(requireContext(), configuration, existing,
onSave = { _, definition ->
store.save(if (existing == null) AlertRuleEditor.addForApps(configuration, listOf(packageName), definition) else AlertRuleEditor.updateForApp(configuration, existing.id, definition))
refresh()
},
onDelete = existing?.let { rule -> { store.save(AlertRuleEditor.remove(store.load(), setOf(rule.id))); refresh() } },
)
}
private fun saveOrder() {
store.save(AlertRuleEditor.reorderForApp(store.load(), packageName, adapter.items.map { it.id }))
}
private fun setAllowMultipleMatches(enabled: Boolean) {
val configuration = store.load()
val old = configuration.appSettings.firstOrNull { it.packageName == packageName }
val setting = (old ?: AlertAppSettings(packageName)).copy(allowMultipleMatches = enabled)
saveAppSettings(configuration, setting)
}
private fun saveAppSettings(configuration: se.ajpanton.notificationsmaster.alerts.AlertConfiguration, setting: AlertAppSettings) {
store.save(configuration.copy(appSettings = configuration.appSettings.filterNot { it.packageName == packageName } +
if (setting.allowMultipleMatches) listOf(setting) else emptyList()))
}
private class RuleAdapter(private val onClick: (AlertRule) -> Unit) : RecyclerView.Adapter<RuleAdapter.Holder>() {
var items = emptyList<AlertRule>(); private set
fun submit(rules: List<AlertRule>) { items = rules; notifyDataSetChanged() }
fun move(from: Int, to: Int) { items = items.toMutableList().apply { add(to, removeAt(from)) }; notifyItemMoved(from, to) }
override fun onCreateViewHolder(parent: android.view.ViewGroup, viewType: Int) = Holder(TextView(parent.context).apply {
layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
val padding = (16 * parent.resources.displayMetrics.density).toInt()
setPadding(padding, padding, padding, padding); textSize = 16f; isClickable = true
})
override fun onBindViewHolder(holder: Holder, position: Int) {
val rule = items[position]
holder.text.text = rule.name ?: "Rule ${position + 1}" + "\n" + rule.sources.joinToString { it.label() } + rule.matcher.value.takeIf { it.isNotBlank() }?.let { " • contains $it" }.orEmpty()
holder.text.setOnClickListener { onClick(rule) }
}
override fun getItemCount() = items.size
class Holder(val text: TextView) : RecyclerView.ViewHolder(text)
}
companion object {
private const val ARG_PACKAGE = "package"
fun newInstance(packageName: String) = AppRulesFragment().apply { arguments = bundleOf(ARG_PACKAGE to packageName) }
}
}
@@ -0,0 +1,265 @@
package se.ajpanton.notificationsmaster
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationsmaster.databinding.FragmentAppVisibilityBinding
import se.ajpanton.notificationsmaster.databinding.ItemVisibilityExceptionBinding
import se.ajpanton.notificationsmaster.visibility.AppVisibilityPolicy
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import se.ajpanton.notificationsmaster.visibility.VisibilityExceptionRule
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
import java.util.Collections
class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) {
private var binding: FragmentAppVisibilityBinding? = null
private lateinit var store: VisibilityPolicyStore
private lateinit var packageName: String
private lateinit var adapter: ExceptionAdapter
private lateinit var touchHelper: ItemTouchHelper
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentAppVisibilityBinding.bind(view)
store = VisibilityPolicyStore(requireContext())
packageName = requireArguments().getString(ARG_PACKAGE)!!
binding!!.appName.text = InstalledApps.name(requireContext(), packageName)
binding!!.packageName.text = packageName
binding!!.returnToList.setOnClickListener(::returnToList)
val icon = requireContext().packageManager.getApplicationIcon(packageName)
adapter = ExceptionAdapter(icon, ::editException, ::deleteException, ::saveExceptions) {
touchHelper.startDrag(it)
}
binding!!.exceptions.layoutManager = LinearLayoutManager(requireContext())
binding!!.exceptions.adapter = adapter
touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) {
override fun isLongPressDragEnabled() = false
override fun onMove(
list: RecyclerView,
from: RecyclerView.ViewHolder,
to: RecyclerView.ViewHolder,
): Boolean {
adapter.move(from.bindingAdapterPosition, to.bindingAdapterPosition)
saveExceptions(adapter.rules())
return true
}
override fun onSwiped(holder: RecyclerView.ViewHolder, direction: Int) = Unit
}).also { it.attachToRecyclerView(binding!!.exceptions) }
binding!!.addException.setOnClickListener { showExceptionDialog(null) }
bind()
}
override fun onResume() {
super.onResume()
activity?.title = getString(R.string.page_notification_visibility)
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
private fun returnToList(@Suppress("UNUSED_PARAMETER") view: View) {
if (!parentFragmentManager.popBackStackImmediate()) {
parentFragmentManager.commit { replace(R.id.content_frame, NotificationVisibilityFragment()) }
}
}
private fun bind() {
val policy = appPolicy()
bindSurface(binding!!.showAod, NotificationSurface.AOD, policy)
bindSurface(binding!!.showLockscreen, NotificationSurface.LOCKSCREEN_COLLAPSED, policy)
bindSurface(binding!!.showUnlocked, NotificationSurface.UNLOCKED_STATUSBAR, policy)
bindSurface(binding!!.showDrawer, NotificationSurface.DRAWER, policy)
adapter.submit(policy.exceptions)
}
private fun bindSurface(toggle: SwitchMaterial, surface: NotificationSurface, policy: AppVisibilityPolicy) {
toggle.setOnCheckedChangeListener(null)
toggle.isChecked = surface !in policy.blockedSurfaces
toggle.setOnCheckedChangeListener { _, visible ->
saveApp {
copy(blockedSurfaces = blockedSurfaces.toMutableSet().apply {
if (visible) remove(surface) else add(surface)
})
}
}
}
private fun appPolicy() = store.load().apps.firstOrNull { it.packageName == packageName }
?: AppVisibilityPolicy(packageName)
private fun saveApp(change: AppVisibilityPolicy.() -> AppVisibilityPolicy) {
store.update { policy ->
val updated = (policy.apps.firstOrNull { it.packageName == packageName }
?: AppVisibilityPolicy(packageName)).change()
val apps = policy.apps.filterNot { it.packageName == packageName }.toMutableList()
if (updated.blockedSurfaces.isNotEmpty() || updated.exceptions.isNotEmpty()) apps += updated
policy.copy(apps = apps.sortedBy { it.packageName })
}
}
private fun saveExceptions(rules: List<VisibilityExceptionRule>) {
saveApp { copy(exceptions = rules) }
}
private fun editException(position: Int) = showExceptionDialog(position)
private fun deleteException(position: Int) {
adapter.submit(adapter.rules().toMutableList().apply { removeAt(position) })
saveExceptions(adapter.rules())
}
private fun showExceptionDialog(position: Int?) {
val existing = position?.let { adapter.rules().getOrNull(it) }
val pattern = EditText(requireContext()).apply {
hint = "Regular expression"
setText(existing?.pattern)
maxLines = 4
setPadding(dp(24), paddingTop, dp(24), paddingBottom)
}
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(if (existing == null) "Add text exception" else "Edit text exception")
.setView(pattern)
.setNegativeButton("Cancel", null)
.setPositiveButton("Save", null)
.create()
dialog.setOnShowListener {
dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val rule = VisibilityExceptionRule(
pattern.text.toString(),
existing?.blockedSurfaces ?: appPolicy().blockedSurfaces,
)
if (!rule.hasValidPattern()) {
pattern.error = "Enter a valid, non-empty regular expression"
return@setOnClickListener
}
val rules = adapter.rules().toMutableList().apply {
if (position == null) add(rule) else set(position, rule)
}
adapter.submit(rules)
saveExceptions(rules)
dialog.dismiss()
}
}
dialog.show()
}
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
private class ExceptionAdapter(
private val appIcon: Drawable,
private val edit: (Int) -> Unit,
private val delete: (Int) -> Unit,
private val changed: (List<VisibilityExceptionRule>) -> Unit,
private val startDrag: (ExceptionHolder) -> Unit,
) : RecyclerView.Adapter<ExceptionHolder>() {
private val items = mutableListOf<VisibilityExceptionRule>()
fun submit(rules: List<VisibilityExceptionRule>) {
items.clear()
items.addAll(rules)
notifyDataSetChanged()
}
fun move(from: Int, to: Int) {
Collections.swap(items, from, to)
notifyItemMoved(from, to)
}
fun rules() = items.toList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ExceptionHolder(
ItemVisibilityExceptionBinding.inflate(LayoutInflater.from(parent.context), parent, false),
)
override fun onBindViewHolder(holder: ExceptionHolder, position: Int) {
val binding = holder.binding
val rule = items[position]
binding.pattern.text = rule.pattern
bindSurface(binding.aodIcon, binding.aodCross, rule, NotificationSurface.AOD)
bindSurface(
binding.lockscreenIcon,
binding.lockscreenCross,
rule,
NotificationSurface.LOCKSCREEN_COLLAPSED,
)
bindSurface(
binding.unlockedIcon,
binding.unlockedCross,
rule,
NotificationSurface.UNLOCKED_STATUSBAR,
)
bindSurface(binding.drawerIcon, binding.drawerCross, rule, NotificationSurface.DRAWER)
binding.aodCell.setOnClickListener { toggle(holder, NotificationSurface.AOD) }
binding.lockscreenCell.setOnClickListener {
toggle(holder, NotificationSurface.LOCKSCREEN_COLLAPSED)
}
binding.unlockedCell.setOnClickListener {
toggle(holder, NotificationSurface.UNLOCKED_STATUSBAR)
}
binding.drawerCell.setOnClickListener { toggle(holder, NotificationSurface.DRAWER) }
binding.edit.setOnClickListener { holder.positionOrNull()?.let(edit) }
binding.delete.setOnClickListener { holder.positionOrNull()?.let(delete) }
binding.dragHandle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) startDrag(holder)
false
}
}
override fun getItemCount() = items.size
private fun toggle(holder: ExceptionHolder, surface: NotificationSurface) {
val position = holder.positionOrNull() ?: return
val current = items[position]
items[position] = current.copy(blockedSurfaces = current.blockedSurfaces.toMutableSet().apply {
if (surface in current.blockedSurfaces) remove(surface) else add(surface)
})
notifyItemChanged(position)
changed(rules())
}
private fun bindSurface(
icon: ImageView,
cross: TextView,
rule: VisibilityExceptionRule,
surface: NotificationSurface,
) {
icon.setImageDrawable(appIcon.constantState?.newDrawable()?.mutate() ?: appIcon)
val hidden = surface in rule.blockedSurfaces
icon.alpha = if (hidden) 0.25f else 1f
cross.visibility = if (hidden) View.VISIBLE else View.GONE
}
private fun ExceptionHolder.positionOrNull() =
bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION }
}
private class ExceptionHolder(val binding: ItemVisibilityExceptionBinding) :
RecyclerView.ViewHolder(binding.root)
companion object {
private const val ARG_PACKAGE = "package"
private const val UP = ItemTouchHelper.UP
private const val DOWN = ItemTouchHelper.DOWN
fun newInstance(packageName: String) = AppVisibilityFragment().apply {
arguments = Bundle().apply { putString(ARG_PACKAGE, packageName) }
}
}
}
@@ -0,0 +1,48 @@
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.view.View
import android.widget.Button
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.databinding.FragmentAppsBinding
class AppsFragment : Fragment(R.layout.fragment_apps) {
private var binding: FragmentAppsBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentAppsBinding.bind(view)
refresh()
}
override fun onResume() {
super.onResume()
activity?.title = getString(R.string.page_apps)
refresh()
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun refresh() {
val container = binding?.apps ?: return
val context = requireContext()
val configuration = AlertConfigurationStore(context).load()
val grouped = configuration.rules.groupBy { it.packageName }
val packages = grouped.keys + configuration.appSettings.map { it.packageName }
container.removeAllViews()
packages.distinct().sortedBy { InstalledApps.name(context, it).lowercase() }.forEach { packageName ->
container.addView(Button(context).apply {
val count = grouped[packageName].orEmpty().size
text = InstalledApps.name(context, packageName) + "\n" + packageName + "" + count + if (count == 1) " rule" else " rules"
isAllCaps = false
setOnClickListener { open(packageName) }
})
}
}
private fun open(packageName: String) = parentFragmentManager.commit {
replace(R.id.content_frame, AppRulesFragment.newInstance(packageName))
addToBackStack(null)
}
}
@@ -0,0 +1,150 @@
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.SeekBar
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationsmaster.databinding.FragmentCardsLayoutBinding
import se.ajpanton.notificationsmaster.visibility.CardsGridSettings
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicy
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
class CardsLayoutFragment : Fragment(R.layout.fragment_cards_layout) {
private var binding: FragmentCardsLayoutBinding? = null
private lateinit var store: VisibilityPolicyStore
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentCardsLayoutBinding.bind(view)
store = VisibilityPolicyStore(requireContext())
val policy = store.load()
bind(
controls(lockscreen = true),
policy.lockscreenCards,
VisibilityPolicy::lockscreenCards,
) { current, settings -> current.copy(lockscreenCards = settings) }
bind(
controls(lockscreen = false),
policy.aodCards,
VisibilityPolicy::aodCards,
) { current, settings -> current.copy(aodCards = settings) }
if (!DeviceCapabilities.isOneUi) {
binding!!.oneUiOnlyNotice.visibility = View.VISIBLE
binding!!.oneUiCardsControls.alpha = DISABLED_CONTROL_ALPHA
binding!!.oneUiCardsControls.disableRecursively()
}
}
override fun onResume() {
super.onResume()
activity?.title = getString(R.string.page_cards_layout)
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
private fun bind(
controls: Controls,
initial: CardsGridSettings,
currentSettings: (VisibilityPolicy) -> CardsGridSettings,
update: (VisibilityPolicy, CardsGridSettings) -> VisibilityPolicy,
) {
controls.rows.bind(0..5, initial.maxRows) { value ->
save(currentSettings, update) { it.copy(maxRows = value) }
}
controls.icons.bind(1..15, initial.maxIconsPerRow) { value ->
save(currentSettings, update) { it.copy(maxIconsPerRow = value) }
}
controls.even.isChecked = initial.evenDistribution
controls.even.setOnCheckedChangeListener { _, checked ->
save(currentSettings, update) { it.copy(evenDistribution = checked) }
}
controls.limit.isChecked = initial.limitToScreenWidth
controls.limit.setOnCheckedChangeListener { _, checked ->
save(currentSettings, update) { it.copy(limitToScreenWidth = checked) }
}
controls.height.max = 90
controls.height.progress = initial.iconHeightPercent - 10
controls.heightValue.text = "${initial.iconHeightPercent}%"
controls.height.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
controls.heightValue.text = "${progress + 10}%"
}
override fun onStartTrackingTouch(seekBar: SeekBar) = Unit
override fun onStopTrackingTouch(seekBar: SeekBar) {
save(currentSettings, update) { it.copy(iconHeightPercent = seekBar.progress + 10) }
}
})
}
private fun save(
currentSettings: (VisibilityPolicy) -> CardsGridSettings,
update: (VisibilityPolicy, CardsGridSettings) -> VisibilityPolicy,
change: (CardsGridSettings) -> CardsGridSettings,
) {
store.update { current ->
update(current, change(currentSettings(current)))
}
}
private fun NumberStepper.bind(range: IntRange, selected: Int, changed: (Int) -> Unit) {
var current = selected
fun show() {
value.text = current.toString()
minus.isEnabled = current > range.first
plus.isEnabled = current < range.last
}
fun step(amount: Int) {
val next = (current + amount).coerceIn(range)
if (next != current) {
current = next
show()
changed(next)
}
}
minus.setOnClickListener { step(-1) }
plus.setOnClickListener { step(1) }
show()
}
private fun controls(lockscreen: Boolean): Controls {
val b = binding!!
return if (lockscreen) b.lockscreenSettings.run {
Controls(
NumberStepper(lockscreenMaxRowsMinus, lockscreenMaxRows, lockscreenMaxRowsPlus),
NumberStepper(lockscreenMaxIconsMinus, lockscreenMaxIcons, lockscreenMaxIconsPlus),
lockscreenEvenDistribution,
lockscreenLimitWidth,
lockscreenIconHeight,
lockscreenIconHeightValue,
)
} else b.aodSettings.run {
Controls(
NumberStepper(aodMaxRowsMinus, aodMaxRows, aodMaxRowsPlus),
NumberStepper(aodMaxIconsMinus, aodMaxIcons, aodMaxIconsPlus),
aodEvenDistribution,
aodLimitWidth,
aodIconHeight,
aodIconHeightValue,
)
}
}
private data class Controls(
val rows: NumberStepper,
val icons: NumberStepper,
val even: SwitchMaterial,
val limit: SwitchMaterial,
val height: SeekBar,
val heightValue: TextView,
)
private data class NumberStepper(val minus: Button, val value: TextView, val plus: Button)
}
@@ -0,0 +1,17 @@
package se.ajpanton.notificationsmaster
import android.os.Build
import android.view.View
import android.view.ViewGroup
object DeviceCapabilities {
val isOneUi: Boolean
get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
}
internal fun View.disableRecursively() {
isEnabled = false
if (this is ViewGroup) repeat(childCount) { getChildAt(it).disableRecursively() }
}
internal const val DISABLED_CONTROL_ALPHA = 0.45f
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.content.res.ColorStateList
@@ -20,17 +20,17 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationlog.capture.SeenApps
import se.ajpanton.notificationlog.databinding.FragmentEventSettingsBinding
import se.ajpanton.notificationlog.settings.AppListItem
import se.ajpanton.notificationlog.settings.AppListOrdering
import se.ajpanton.notificationlog.settings.AppRuleMode
import se.ajpanton.notificationlog.settings.ListedApp
import se.ajpanton.notificationlog.settings.AppFilterSettings
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore
import se.ajpanton.notificationsmaster.capture.SeenApps
import se.ajpanton.notificationsmaster.databinding.FragmentEventSettingsBinding
import se.ajpanton.notificationsmaster.settings.AppListItem
import se.ajpanton.notificationsmaster.settings.AppListOrdering
import se.ajpanton.notificationsmaster.settings.AppRuleMode
import se.ajpanton.notificationsmaster.settings.ListedApp
import se.ajpanton.notificationsmaster.settings.AppFilterSettings
import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
import se.ajpanton.notificationsmaster.settings.LoggingType
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -123,7 +123,7 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
currentBinding.appListLoading.visibility = View.VISIBLE
currentBinding.appListRefresh.isRefreshing = true
val rule = store.load()
val seen = SeenApps.snapshot()
val seen = SeenApps.snapshot(requireContext())
val context = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
val apps = withContext(Dispatchers.IO) {
@@ -1,13 +1,13 @@
package se.ajpanton.notificationlog
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.notificationlog.databinding.FragmentFilterLoggingBinding
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationsmaster.databinding.FragmentFilterLoggingBinding
import se.ajpanton.notificationsmaster.settings.CaptureSettingsStore
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
import se.ajpanton.notificationsmaster.settings.LoggingType
class FilterLoggingFragment : Fragment(R.layout.fragment_filter_logging) {
private var binding: FragmentFilterLoggingBinding? = null
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog
package se.ajpanton.notificationsmaster
import android.app.Dialog
import android.graphics.Bitmap
@@ -19,7 +19,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -0,0 +1,17 @@
package se.ajpanton.notificationsmaster
import android.content.Context
data class InstalledApp(val name: String, val packageName: String) {
override fun toString() = "$name ($packageName)"
}
object InstalledApps {
fun all(context: Context): List<InstalledApp> = context.packageManager.getInstalledApplications(0).map {
InstalledApp(context.packageManager.getApplicationLabel(it).toString(), it.packageName)
}.sortedBy { it.name.lowercase() }
fun name(context: Context, packageName: String): String = runCatching {
context.packageManager.getApplicationLabel(context.packageManager.getApplicationInfo(packageName, 0)).toString()
}.getOrDefault(packageName)
}
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog
package se.ajpanton.notificationsmaster
import android.content.ClipData
import android.os.Bundle
@@ -9,14 +9,14 @@ import android.widget.Spinner
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationlog.databinding.FragmentLogDisplayBinding
import se.ajpanton.notificationlog.settings.DisplayEvent
import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettings
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
import se.ajpanton.notificationlog.settings.TimestampClockFormat
import se.ajpanton.notificationlog.settings.TimestampDateFormat
import se.ajpanton.notificationlog.settings.TimestampZone
import se.ajpanton.notificationsmaster.databinding.FragmentLogDisplayBinding
import se.ajpanton.notificationsmaster.settings.DisplayEvent
import se.ajpanton.notificationsmaster.settings.LogField
import se.ajpanton.notificationsmaster.settings.LogViewSettings
import se.ajpanton.notificationsmaster.settings.LogViewSettingsStore
import se.ajpanton.notificationsmaster.settings.TimestampClockFormat
import se.ajpanton.notificationsmaster.settings.TimestampDateFormat
import se.ajpanton.notificationsmaster.settings.TimestampZone
class LogDisplayFragment : Fragment(R.layout.fragment_log_display) {
private var binding: FragmentLogDisplayBinding? = null
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.hardware.biometrics.BiometricPrompt
@@ -9,6 +9,7 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.WindowManager
import android.widget.RelativeLayout
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
@@ -22,15 +23,16 @@ import androidx.core.view.forEach
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.commit
import com.google.android.material.navigation.NavigationView
import se.ajpanton.notificationlog.databinding.ActivityMainBinding
import se.ajpanton.notificationlog.settings.AppLockStore
import se.ajpanton.notificationlog.settings.LogAppFilterStore
import se.ajpanton.notificationsmaster.databinding.ActivityMainBinding
import se.ajpanton.notificationsmaster.settings.AppLockStore
import se.ajpanton.notificationsmaster.settings.LogAppFilterStore
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
private lateinit var binding: ActivityMainBinding
private lateinit var drawerToggle: ActionBarDrawerToggle
private var permanentSidebar = false
private var needsUnlock = true
private var unlockInProgress = false
private lateinit var appLockStore: AppLockStore
private var currentItemId = R.id.nav_view_logs
private var drawerNavigationBasePaddingLeft: Int? = null
@@ -39,12 +41,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
appLockStore = AppLockStore(this)
updateSensitiveWindowPolicy(appLockStore.enabled)
// Keep every app surface below the system bars rather than relying on edge-to-edge drawing.
WindowCompat.setDecorFitsSystemWindows(window, true)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
showLockedOverlay(appLockStore.enabled)
val isNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
WindowInsetsControllerCompat(window, binding.root).isAppearanceLightNavigationBars = !isNightMode
@@ -78,7 +78,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
})
currentItemId = savedInstanceState?.getInt(STATE_CURRENT_ITEM_ID) ?: R.id.nav_view_logs
if (savedInstanceState == null) {
navigateTo(currentItemId)
} else {
val page = Page.fromMenuItem(currentItemId)
refreshLogProtection(requestUnlock = hasWindowFocus())
syncDrawerSelection(currentItemId)
title = getString(page.titleRes)
}
applyNavigationDrawerInsets()
syncDrawerToggle()
}
@@ -90,33 +97,34 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
override fun onResume() {
super.onResume()
if (needsUnlock && appLockStore.enabled) {
showLockedOverlay(true)
requestUnlock()
} else {
showLockedOverlay(false)
}
refreshLogProtection(requestUnlock = true)
}
override fun onStop() {
super.onStop()
if (!isChangingConfigurations) {
if (!isChangingConfigurations && isProtectedLogPage()) {
needsUnlock = true
if (appLockStore.enabled) showLockedOverlay(true)
showLockedOverlay(true)
}
}
private fun requestUnlock() {
if (!isProtectedLogPage() || !needsUnlock || unlockInProgress) return
unlockInProgress = true
BiometricPrompt.Builder(this)
.setTitle("Unlock Notification Log")
.setTitle("Unlock notification logs")
.setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL)
.build()
.authenticate(CancellationSignal(), mainExecutor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) {
needsUnlock = false
showLockedOverlay(false)
unlockInProgress = false
refreshLogProtection()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) {
unlockInProgress = false
if (isProtectedLogPage()) navigateTo(R.id.nav_alerts)
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) { finishAndRemoveTask() }
})
}
@@ -127,13 +135,19 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
/** Called immediately when the Settings switch changes, before a task snapshot can be captured. */
fun setAppLockEnabled(enabled: Boolean) {
appLockStore.enabled = enabled
updateSensitiveWindowPolicy(enabled)
needsUnlock = enabled
refreshLogProtection(requestUnlock = true)
}
private fun updateSensitiveWindowPolicy(locked: Boolean) {
setRecentsScreenshotEnabled(!locked)
if (locked) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
private fun isProtectedLogPage() = appLockStore.enabled && currentItemId == R.id.nav_view_logs
private fun refreshLogProtection(requestUnlock: Boolean = false) {
val protectedLogs = isProtectedLogPage()
setRecentsScreenshotEnabled(!protectedLogs)
if (protectedLogs) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
showLockedOverlay(protectedLogs && needsUnlock)
if (protectedLogs && needsUnlock && requestUnlock) binding.root.post(::requestUnlock)
}
override fun onNavigationItemSelected(item: MenuItem): Boolean {
@@ -146,21 +160,31 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private fun navigateTo(itemId: Int) {
currentItemId = itemId
refreshLogProtection(requestUnlock = hasWindowFocus())
syncDrawerSelection(itemId)
val page = Page.fromMenuItem(itemId)
supportFragmentManager.popBackStackImmediate(
null,
androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE,
)
supportFragmentManager.commit {
replace(
R.id.content_frame,
when {
page == Page.VIEW_LOGS -> ViewLogsFragment()
page == Page.SETTINGS -> SettingsFragment()
page == Page.LOG_DISPLAY -> LogDisplayFragment()
page == Page.FILTER_LOGGING -> FilterLoggingFragment()
page == Page.FILTER_APPS -> FilterAppsFragment.newInstance()
else -> PageFragment.newInstance(page.titleRes)
when (page) {
Page.VIEW_LOGS -> ViewLogsFragment()
Page.SETTINGS -> SettingsFragment()
Page.ALERTS -> AlertsFragment()
Page.PROFILES -> ProfilesFragment()
Page.RULES -> RulesFragment()
Page.APPS -> AppsFragment()
Page.NOTIFICATION_VISIBILITY -> NotificationVisibilityFragment()
Page.CARDS_LAYOUT -> CardsLayoutFragment()
Page.MISCELLANEOUS -> MiscellaneousFragment()
Page.LOG_DISPLAY -> LogDisplayFragment()
Page.FILTER_LOGGING -> FilterLoggingFragment()
Page.FILTER_APPS -> FilterAppsFragment.newInstance()
},
)
runOnCommit(::applyPageTitleVisibility)
}
title = getString(page.titleRes)
invalidateOptionsMenu()
@@ -269,9 +293,16 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.START)
binding.navViewDrawer.visibility = View.VISIBLE
}
(binding.contentFrame.layoutParams as RelativeLayout.LayoutParams).also { params ->
params.removeRule(if (permanentSidebar) RelativeLayout.BELOW else RelativeLayout.ALIGN_PARENT_TOP)
params.addRule(
if (permanentSidebar) RelativeLayout.ALIGN_PARENT_TOP else RelativeLayout.BELOW,
if (permanentSidebar) RelativeLayout.TRUE else R.id.toolbar,
)
binding.contentFrame.layoutParams = params
}
updateNavigationHeaderHeight(binding.navViewDrawer)
updateNavigationHeaderHeight(binding.navViewPermanent)
applyPageTitleVisibility()
}
private fun updateNavigationHeaderHeight(navigationView: NavigationView) {
@@ -297,11 +328,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
return visible
}
private fun applyPageTitleVisibility() {
(supportFragmentManager.findFragmentById(R.id.content_frame) as? PageFragment)
?.setPageTitleVisible(permanentSidebar)
}
private fun syncDrawerToggle() {
drawerToggle.syncState()
drawerToggle.drawerArrowDrawable.color = getColor(R.color.on_primary)
@@ -345,6 +371,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
) {
VIEW_LOGS(R.id.nav_view_logs, R.string.page_view_logs),
SETTINGS(R.id.nav_settings, R.string.page_settings),
ALERTS(R.id.nav_alerts, R.string.page_alerts),
PROFILES(R.id.nav_profiles, R.string.page_profiles),
RULES(R.id.nav_rules, R.string.page_rules),
APPS(R.id.nav_apps, R.string.page_apps),
NOTIFICATION_VISIBILITY(R.id.nav_notification_visibility, R.string.page_notification_visibility),
CARDS_LAYOUT(R.id.nav_cards_layout, R.string.page_cards_layout),
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),
@@ -0,0 +1,115 @@
package se.ajpanton.notificationsmaster
import android.Manifest
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationsmaster.databinding.FragmentMiscellaneousBinding
import se.ajpanton.notificationsmaster.debug.DebugNotifications
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
private var pendingCount: Int? = null
private var pendingCycle = false
private var updatingCycle = false
private val permissionRequest = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
val context = context ?: return@registerForActivityResult
if (granted) {
pendingCount?.let { DebugNotifications.setCount(context, it) }
if (pendingCycle) DebugNotifications.setCycling(context, true)
} else {
Toast.makeText(context, R.string.debug_notification_permission_required, Toast.LENGTH_SHORT).show()
}
pendingCount = null
pendingCycle = false
refreshDebugNotifications()
}
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)
}
if (!DeviceCapabilities.isOneUi) {
binding!!.oneUiOnlyNotice.visibility = View.VISIBLE
binding!!.oneUiMiscControls.alpha = DISABLED_CONTROL_ALPHA
binding!!.oneUiMiscControls.disableRecursively()
}
binding!!.debugMinus10.setOnClickListener { changeDebugCount(-10) }
binding!!.debugMinus1.setOnClickListener { changeDebugCount(-1) }
binding!!.debugPlus1.setOnClickListener { changeDebugCount(1) }
binding!!.debugPlus10.setOnClickListener { changeDebugCount(10) }
binding!!.debugReset.setOnClickListener { setDebugCount(0) }
binding!!.debugCycle.setOnCheckedChangeListener { _, enabled ->
if (updatingCycle) return@setOnCheckedChangeListener
if (enabled && !DebugNotifications.hasPermission(requireContext())) {
pendingCycle = true
permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
DebugNotifications.setCycling(requireContext(), enabled)
}
}
}
override fun onResume() {
super.onResume()
activity?.title = getString(R.string.page_miscellaneous)
DebugNotifications.apply(requireContext())
refreshDebugNotifications()
}
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)) }
}
}
private fun changeDebugCount(delta: Int) = setDebugCount(DebugNotifications.count(requireContext()) + delta)
private fun setDebugCount(count: Int) {
val desired = count.coerceIn(0, DebugNotifications.MAX_COUNT)
if (desired > 0 && !DebugNotifications.hasPermission(requireContext())) {
pendingCount = desired
permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS)
return
}
DebugNotifications.setCount(requireContext(), desired)
refreshDebugNotifications()
}
private fun refreshDebugNotifications() {
val context = context ?: return
val count = DebugNotifications.count(context)
binding?.debugCount?.text = count.toString()
binding?.debugCycle?.isEnabled = count > 0
updatingCycle = true
binding?.debugCycle?.isChecked = DebugNotifications.isCycling(context)
updatingCycle = false
}
}
@@ -0,0 +1,231 @@
package se.ajpanton.notificationsmaster
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import se.ajpanton.notificationsmaster.capture.SeenApps
import se.ajpanton.notificationsmaster.databinding.FragmentNotificationVisibilityBinding
import se.ajpanton.notificationsmaster.databinding.ItemNotificationVisibilityAppBinding
import se.ajpanton.notificationsmaster.visibility.AppVisibilityPolicy
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_visibility) {
private var binding: FragmentNotificationVisibilityBinding? = null
private lateinit var store: VisibilityPolicyStore
private val adapter = AppAdapter(::openApp, ::toggleSurface)
private var apps = emptyList<AppSnapshot>()
private var appPolicies = emptyMap<String, AppVisibilityPolicy>()
private var loadGeneration = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentNotificationVisibilityBinding.bind(view)
store = VisibilityPolicyStore(requireContext())
binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
binding!!.appList.adapter = adapter
binding!!.appListRefresh.setOnRefreshListener(::loadApps)
binding!!.appSearch.doAfterTextChanged { showApps() }
bindEnabled()
loadApps()
}
override fun onResume() {
super.onResume()
activity?.title = getString(R.string.page_notification_visibility)
if (binding != null) {
bindEnabled()
showApps()
}
}
override fun onDestroyView() {
loadGeneration++
binding = null
super.onDestroyView()
}
private fun bindEnabled() {
val toggle = binding?.visibilityEnabled ?: return
val policy = store.load()
appPolicies = policy.apps.associateBy(AppVisibilityPolicy::packageName)
toggle.setOnCheckedChangeListener(null)
toggle.isChecked = policy.enabled
toggle.setOnCheckedChangeListener { _, enabled -> store.update { it.copy(enabled = enabled) } }
}
private fun loadApps() {
val currentBinding = binding ?: return
val generation = ++loadGeneration
val context = requireContext().applicationContext
val policies = appPolicies
currentBinding.appListLoading.visibility = View.VISIBLE
currentBinding.appListRefresh.isRefreshing = true
viewLifecycleOwner.lifecycleScope.launch {
val loaded = withContext(Dispatchers.IO) {
val manager = context.packageManager
SeenApps.snapshot(context).mapNotNull { packageName ->
runCatching {
val info = manager.getApplicationInfo(packageName, 0)
AppSnapshot(
InstalledApp(manager.getApplicationLabel(info).toString(), packageName),
manager.getApplicationIcon(info),
)
}.getOrNull()
}.sortedWith(
compareBy<AppSnapshot> { policies[it.app.packageName]?.isEdited() != true }
.thenBy { it.app.name.lowercase() }
.thenBy { it.app.packageName },
)
}
if (binding === currentBinding && generation == loadGeneration) {
apps = loaded
currentBinding.appListLoading.visibility = View.GONE
currentBinding.appListRefresh.isRefreshing = false
showApps()
}
}
}
private fun showApps() {
val currentBinding = binding ?: return
val terms = currentBinding.appSearch.text.toString().lowercase().trim()
.split(Regex("\\s+")).filter(String::isNotEmpty)
val rows = apps.filter { app ->
val searchable = "${app.app.name}\n${app.app.packageName}".lowercase()
terms.all(searchable::contains)
}.map { app -> AppRow(app, appPolicies[app.app.packageName]) }
adapter.submit(rows)
currentBinding.appListEmpty.visibility = if (
rows.isEmpty() && currentBinding.appListLoading.visibility != View.VISIBLE
) View.VISIBLE else View.GONE
}
private fun toggleSurface(row: AppRow, surface: NotificationSurface) {
val saved = store.update { policy ->
val current = policy.apps.firstOrNull { it.packageName == row.app.app.packageName }
?: AppVisibilityPolicy(row.app.app.packageName)
val updated = current.copy(blockedSurfaces = current.blockedSurfaces.toMutableSet().apply {
if (surface in current.blockedSurfaces) remove(surface) else add(surface)
})
val apps = policy.apps.filterNot { it.packageName == current.packageName }.toMutableList()
if (updated.isEdited()) apps += updated
policy.copy(apps = apps.sortedBy(AppVisibilityPolicy::packageName))
}
appPolicies = saved.apps.associateBy(AppVisibilityPolicy::packageName)
adapter.updatePolicy(row.app.app.packageName, appPolicies[row.app.app.packageName])
}
private fun openApp(app: InstalledApp) = parentFragmentManager.commit {
replace(R.id.content_frame, AppVisibilityFragment.newInstance(app.packageName))
addToBackStack(null)
}
private data class AppSnapshot(val app: InstalledApp, val icon: Drawable)
private data class AppRow(val app: AppSnapshot, val policy: AppVisibilityPolicy?)
private class AppAdapter(
private val open: (InstalledApp) -> Unit,
private val toggle: (AppRow, NotificationSurface) -> Unit,
) : RecyclerView.Adapter<AppHolder>() {
private var rows = emptyList<AppRow>()
fun submit(newRows: List<AppRow>) {
rows = newRows
notifyDataSetChanged()
}
fun updatePolicy(packageName: String, policy: AppVisibilityPolicy?) {
val position = rows.indexOfFirst { it.app.app.packageName == packageName }
if (position < 0) return
rows = rows.toMutableList().also { it[position] = it[position].copy(policy = policy) }
notifyItemChanged(position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AppHolder(
ItemNotificationVisibilityAppBinding.inflate(LayoutInflater.from(parent.context), parent, false),
)
override fun onBindViewHolder(holder: AppHolder, position: Int) {
val row = rows[position]
val binding = holder.binding
val policy = row.policy
binding.visibilityAppName.text = row.app.app.name
binding.visibilityAppPackage.text = row.app.app.packageName
val exceptionCount = policy?.exceptions?.size ?: 0
binding.visibilityAppExceptions.visibility = if (exceptionCount > 0) View.VISIBLE else View.GONE
binding.visibilityAppExceptions.text = "$exceptionCount text exception" + if (exceptionCount == 1) "" else "s"
binding.visibilityAppRoot.setBackgroundColor(
if (policy?.isEdited() == true) ContextCompat.getColor(
binding.root.context,
R.color.visibility_edited_background,
) else Color.TRANSPARENT,
)
bindSurface(binding.visibilityAodIcon, binding.visibilityAodCross, row, NotificationSurface.AOD)
bindSurface(
binding.visibilityLockscreenIcon,
binding.visibilityLockscreenCross,
row,
NotificationSurface.LOCKSCREEN_COLLAPSED,
)
bindSurface(
binding.visibilityUnlockedIcon,
binding.visibilityUnlockedCross,
row,
NotificationSurface.UNLOCKED_STATUSBAR,
)
bindSurface(
binding.visibilityDrawerIcon,
binding.visibilityDrawerCross,
row,
NotificationSurface.DRAWER,
)
binding.visibilityAodCell.setOnClickListener { toggle(row, NotificationSurface.AOD) }
binding.visibilityLockscreenCell.setOnClickListener {
toggle(row, NotificationSurface.LOCKSCREEN_COLLAPSED)
}
binding.visibilityUnlockedCell.setOnClickListener {
toggle(row, NotificationSurface.UNLOCKED_STATUSBAR)
}
binding.visibilityDrawerCell.setOnClickListener {
toggle(row, NotificationSurface.DRAWER)
}
binding.visibilityAppRoot.setOnClickListener { open(row.app.app) }
}
override fun getItemCount() = rows.size
private fun bindSurface(
icon: ImageView,
cross: TextView,
row: AppRow,
surface: NotificationSurface,
) {
icon.setImageDrawable(row.app.icon.constantState?.newDrawable()?.mutate() ?: row.app.icon)
val hidden = surface in row.policy?.blockedSurfaces.orEmpty()
icon.alpha = if (hidden) 0.25f else 1f
cross.visibility = if (hidden) View.VISIBLE else View.GONE
}
}
private class AppHolder(val binding: ItemNotificationVisibilityAppBinding) :
RecyclerView.ViewHolder(binding.root)
private companion object {
fun AppVisibilityPolicy.isEdited() = blockedSurfaces.isNotEmpty() || exceptions.isNotEmpty()
}
}
@@ -1,19 +1,20 @@
package se.ajpanton.notificationlog
package se.ajpanton.notificationsmaster
import android.app.Application
import android.content.pm.PackageManager
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.NotificationListenerComponentController
import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.module.VisibilityPolicySync
import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
class NotificationLogApplication : Application() {
class NotificationsMasterApplication : Application() {
override fun onCreate() {
super.onCreate()
synchronizeListenerComponent()
Thread(::removeStaleAppStorage, "notification-log-storage-cleanup").start()
VisibilityPolicySync.publishStored(this)
Thread(::removeStaleAppStorage, "notifications-master-storage-cleanup").start()
}
/**
@@ -22,12 +23,7 @@ class NotificationLogApplication : Application() {
* Reconcile it before the UI starts so fresh default rules can bind again.
*/
private fun synchronizeListenerComponent() {
val rules = LoggingRuleStore(this)
NotificationListenerComponentController.update(
this,
LoggingType.entries.associateWith(rules::ruleFor),
PerAppEventSettingsStore(this).hasEnabledEventOverride(),
)
NotificationListenerComponentController.synchronize(this)
}
private fun removeStaleAppStorage() {
@@ -36,6 +32,7 @@ class NotificationLogApplication : Application() {
).mapTo(mutableSetOf()) { it.packageName }
AppFilterSettingsStore(this).removeUninstalledPackages(installedPackages)
PerAppEventSettingsStore(this).removeUninstalledPackages(installedPackages)
VisibilityPolicyStore(this).removeUninstalledPackages(installedPackages)
EncryptedNotificationLogStore(this).removeOrphanedImages()
}
}
@@ -0,0 +1,167 @@
package se.ajpanton.notificationsmaster
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Bundle
import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.VibratorManager
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.alerts.AlertProfile
import se.ajpanton.notificationsmaster.databinding.FragmentProfilesBinding
import java.util.UUID
class ProfilesFragment : Fragment(R.layout.fragment_profiles) {
private var binding: FragmentProfilesBinding? = null
private lateinit var store: AlertConfigurationStore
private var selectedSoundUri: String? = null
private var soundButton: Button? = null
private var soundName: android.widget.TextView? = null
private val chooseSound = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
selectedSoundUri = result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)?.toString()
updateSoundSelection()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentProfilesBinding.bind(view)
store = AlertConfigurationStore(requireContext())
binding!!.addProfile.setOnClickListener { showEditor() }
refresh()
}
override fun onDestroyView() {
binding = null
soundButton = null
soundName = null
super.onDestroyView()
}
private fun refresh() {
val container = binding!!.profiles
container.removeAllViews()
store.load().profiles.forEach { profile ->
container.addView(Button(requireContext()).apply {
text = profile.name + "\n" + profileSummary(profile)
isAllCaps = false
setOnClickListener { showEditor(profile) }
})
}
}
private fun showEditor(existing: AlertProfile? = null) {
selectedSoundUri = existing?.soundUri
val context = requireContext()
val form = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(24), 0, dp(24), 0)
}
val name = EditText(context).apply {
hint = "Profile name"
setText(existing?.name)
}
val vibration = EditText(context).apply {
hint = "Vibration timing in milliseconds, for example 0, 200, 100, 200"
setText(existing?.vibrationPattern?.joinToString(", ") ?: "0, 200")
inputType = android.text.InputType.TYPE_CLASS_TEXT
}
val pickerButton = Button(context).apply {
text = if (selectedSoundUri == null) "Choose sound" else "Change sound"
setOnClickListener {
chooseSound.launch(
Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)
.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, selectedSoundUri?.let(Uri::parse)),
)
}
}
soundButton = pickerButton
val selectedSound = android.widget.TextView(context).apply { text = soundLabel() }
soundName = selectedSound
val testVibration = Button(context).apply {
text = "Test vibration"
setOnClickListener {
parsePattern(vibration.text.toString())?.takeIf { it.any { duration -> duration > 0 } }?.let { pattern ->
context.getSystemService(VibratorManager::class.java).defaultVibrator.vibrate(
VibrationEffect.createWaveform(pattern.toLongArray(), -1),
VibrationAttributes.Builder().setUsage(VibrationAttributes.USAGE_NOTIFICATION).build(),
)
} ?: run { vibration.error = "Enter a vibration pattern first" }
}
}
form.addView(name)
form.addView(pickerButton)
form.addView(selectedSound)
form.addView(vibration)
form.addView(testVibration)
val dialog = MaterialAlertDialogBuilder(context)
.setTitle(if (existing == null) "Add profile" else "Edit profile")
.setView(form)
.setNegativeButton("Cancel", null)
.setPositiveButton("Save", null)
.apply {
if (existing != null) setNeutralButton("Delete", null)
}
.show()
dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).setOnClickListener {
val pattern = parsePattern(vibration.text.toString()) ?: run {
vibration.error = "Use non-negative comma-separated milliseconds"
return@setOnClickListener
}
val profileName = name.text.toString().trim()
if (profileName.isEmpty() || selectedSoundUri == null && pattern.none { it > 0 }) {
name.error = if (profileName.isEmpty()) "Enter a name" else null
vibration.error = if (selectedSoundUri == null && pattern.none { it > 0 }) "Choose a sound or vibration" else null
return@setOnClickListener
}
val profile = AlertProfile(existing?.id ?: UUID.randomUUID().toString(), profileName, selectedSoundUri, pattern)
val configuration = store.load()
store.save(configuration.copy(profiles = configuration.profiles.filterNot { it.id == profile.id } + profile))
dialog.dismiss()
refresh()
}
if (existing != null) {
dialog.getButton(android.content.DialogInterface.BUTTON_NEUTRAL).setOnClickListener {
val configuration = store.load()
if (configuration.rules.any { it.profileId == existing.id }) {
Toast.makeText(context, "Remove or change rules using this profile first", Toast.LENGTH_LONG).show()
return@setOnClickListener
}
store.save(configuration.copy(profiles = configuration.profiles.filterNot { it.id == existing.id }))
dialog.dismiss()
refresh()
}
}
}
private fun parsePattern(text: String): List<Long>? = text.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { it.toLongOrNull() ?: return null }
.takeIf { it.all { value -> value >= 0 } }
?: emptyList()
private fun profileSummary(profile: AlertProfile): String = buildList {
if (profile.soundUri != null) add("Sound")
if (profile.vibrationPattern.any { it > 0 }) add("Vibration")
}.joinToString(" + ")
private fun updateSoundSelection() {
soundButton?.text = if (selectedSoundUri == null) "Choose sound" else "Change sound"
soundName?.text = soundLabel()
}
private fun soundLabel(): String = selectedSoundUri?.let { uri ->
RingtoneManager.getRingtone(requireContext(), Uri.parse(uri))?.getTitle(requireContext()) ?: uri
} ?: "No sound selected"
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
}
@@ -0,0 +1,63 @@
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.view.View
import android.widget.Button
import androidx.fragment.app.Fragment
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.alerts.AlertOutcome
import se.ajpanton.notificationsmaster.alerts.AlertRuleDefinition
import se.ajpanton.notificationsmaster.alerts.AlertRuleEditor
import se.ajpanton.notificationsmaster.alerts.AlertSource
import se.ajpanton.notificationsmaster.databinding.FragmentRulesBinding
class RulesFragment : Fragment(R.layout.fragment_rules) {
private var binding: FragmentRulesBinding? = null
private lateinit var store: AlertConfigurationStore
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentRulesBinding.bind(view)
store = AlertConfigurationStore(requireContext())
binding!!.addRule.setOnClickListener { showEditor() }
refresh()
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun refresh() {
val configuration = store.load()
binding!!.rules.removeAllViews()
AlertRuleEditor.consolidate(configuration.rules).forEach { rule ->
binding!!.rules.addView(Button(requireContext()).apply {
text = rule.definition.outcomeLabel(configuration) + "\n" +
rule.packageNames.joinToString() + "" + rule.definition.sources.joinToString { it.label() }
isAllCaps = false
setOnClickListener { showEditor(rule.ruleIds) }
})
}
}
private fun showEditor(ruleIds: Set<String>? = null) {
val configuration = store.load()
val existing = ruleIds?.let { ids -> configuration.rules.first { it.id in ids } }
AlertRuleDialog.show(
requireContext(), configuration, existing, if (existing == null) InstalledApps.all(requireContext()) else null,
onSave = { packageNames, definition ->
val updated = if (ruleIds == null) {
AlertRuleEditor.addForApps(configuration, packageNames.orEmpty(), definition)
} else {
AlertRuleEditor.updateConsolidated(configuration, ruleIds, definition)
}
store.save(updated)
refresh()
},
onDelete = ruleIds?.let { ids -> { store.save(AlertRuleEditor.remove(store.load(), ids)); refresh() } },
)
}
private fun AlertRuleDefinition.outcomeLabel(configuration: se.ajpanton.notificationsmaster.alerts.AlertConfiguration) = when (outcome) {
AlertOutcome.PLAY_PROFILE -> configuration.profiles.firstOrNull { it.id == profileId }?.name ?: "Missing profile"
AlertOutcome.SILENCE_ORIGINAL -> "Silence original alert"
AlertOutcome.PASS_THROUGH -> "Keep original alert"
}
}
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog
package se.ajpanton.notificationsmaster
import android.content.Context
import android.graphics.Canvas
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.net.Uri
@@ -9,17 +9,17 @@ import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import se.ajpanton.notificationlog.databinding.FragmentSettingsBinding
import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
import se.ajpanton.notificationlog.settings.AppLockStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.capture.NotificationCaptureService
import se.ajpanton.notificationlog.export.LogExporter
import se.ajpanton.notificationlog.settings.StorageLimits
import se.ajpanton.notificationlog.settings.StorageLimitsStore
import se.ajpanton.notificationlog.settings.LogAppFilterStore
import se.ajpanton.notificationsmaster.databinding.FragmentSettingsBinding
import se.ajpanton.notificationsmaster.settings.LogField
import se.ajpanton.notificationsmaster.settings.LogViewSettingsStore
import se.ajpanton.notificationsmaster.settings.AppLockStore
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
import se.ajpanton.notificationsmaster.export.LogExporter
import se.ajpanton.notificationsmaster.settings.StorageLimits
import se.ajpanton.notificationsmaster.settings.StorageLimitsStore
import se.ajpanton.notificationsmaster.settings.LogAppFilterStore
import java.util.zip.ZipOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -107,7 +107,7 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
MaterialAlertDialogBuilder(requireContext())
.setItems(arrayOf("CSV (no images)", "Formatted text (no images)", "HTML ZIP")) { _, which ->
pendingExport = ExportFormat.entries[which]
createDocument.launch("notification-log.${pendingExport!!.extension}")
createDocument.launch("notifications-master.${pendingExport!!.extension}")
}
.show()
}
@@ -148,7 +148,7 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
private fun writeCsv(
writer: java.io.BufferedWriter,
logStore: EncryptedNotificationLogStore,
settings: se.ajpanton.notificationlog.settings.LogViewSettings,
settings: se.ajpanton.notificationsmaster.settings.LogViewSettings,
context: android.content.Context,
) = writer.use {
it.write(LogExporter.csvHeader(settings))
@@ -161,7 +161,7 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
private fun writeFormatted(
writer: java.io.BufferedWriter,
logStore: EncryptedNotificationLogStore,
settings: se.ajpanton.notificationlog.settings.LogViewSettings,
settings: se.ajpanton.notificationsmaster.settings.LogViewSettings,
context: android.content.Context,
) = writer.use {
val headers = LogExporter.headers(settings)
@@ -181,11 +181,11 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
private fun writeHtmlZip(
zip: ZipOutputStream,
logStore: EncryptedNotificationLogStore,
settings: se.ajpanton.notificationlog.settings.LogViewSettings,
settings: se.ajpanton.notificationsmaster.settings.LogViewSettings,
context: android.content.Context,
) = zip.use { zipOutput ->
val imageStore = EncryptedImageStore(context)
zipOutput.putNextEntry(java.util.zip.ZipEntry("notification-log.html"))
zipOutput.putNextEntry(java.util.zip.ZipEntry("notifications-master.html"))
zipOutput.write(LogExporter.htmlStart(settings).encodeToByteArray())
logStore.forEachNewest { entry ->
val imagePath = entry.imageId
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog
package se.ajpanton.notificationsmaster
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
@@ -18,19 +18,19 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.data.NewestLogCursor
import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding
import se.ajpanton.notificationlog.model.NotificationAction
import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettings
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
import se.ajpanton.notificationlog.settings.LogAppFilterStore
import se.ajpanton.notificationlog.settings.TimestampFormatter
import se.ajpanton.notificationlog.settings.DisplayEvent
import se.ajpanton.notificationlog.capture.NotificationCaptureService
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.data.NewestLogCursor
import se.ajpanton.notificationsmaster.databinding.FragmentViewLogsBinding
import se.ajpanton.notificationsmaster.model.NotificationAction
import se.ajpanton.notificationsmaster.model.NotificationLogEntry
import se.ajpanton.notificationsmaster.settings.LogField
import se.ajpanton.notificationsmaster.settings.LogViewSettings
import se.ajpanton.notificationsmaster.settings.LogViewSettingsStore
import se.ajpanton.notificationsmaster.settings.LogAppFilterStore
import se.ajpanton.notificationsmaster.settings.TimestampFormatter
import se.ajpanton.notificationsmaster.settings.DisplayEvent
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
import androidx.fragment.app.Fragment
import androidx.core.content.ContextCompat
import androidx.core.view.doOnPreDraw
@@ -45,7 +45,7 @@ import kotlinx.coroutines.withContext
class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private var binding: FragmentViewLogsBinding? = null
private val expandedIds = mutableSetOf<String>()
private val rows = mutableListOf<LogRow>()
private val rows = mutableListOf<NotificationLogEntry>()
private var nextCursor: NewestLogCursor? = null
private var loading = false
private var noMoreRows = false
@@ -106,7 +106,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
nextCursor = page.nextCursor
noMoreRows = nextCursor == null
val visibleBefore = visibleRows(LogViewSettingsStore(requireContext()).load()).size
rows += page.entries.map(::LogRow)
rows += page.entries
binding?.let(::render)
val visibleAfter = visibleRows(LogViewSettingsStore(requireContext()).load()).size
val skipFilteredPage = appFilterStore.selectedPackages().isNotEmpty() &&
@@ -181,15 +181,15 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
loadLogs(reset = true)
}
private fun visibleRows(settings: LogViewSettings): List<LogRow> {
private fun visibleRows(settings: LogViewSettings): List<NotificationLogEntry> {
val selectedPackages = appFilterStore.selectedPackages()
return rows.filter { row ->
eventFor(row.entry.action) in settings.visibleEvents &&
(selectedPackages.isEmpty() || row.entry.packageName in selectedPackages)
return rows.filter { entry ->
eventFor(entry.action) in settings.visibleEvents &&
(selectedPackages.isEmpty() || entry.packageName in selectedPackages)
}
}
private fun logRowView(row: LogRow, settings: LogViewSettings, metadataColumnWidth: Int): View = LinearLayout(requireContext()).apply {
private fun logRowView(entry: NotificationLogEntry, settings: LogViewSettings, metadataColumnWidth: Int): View = LinearLayout(requireContext()).apply {
orientation = LinearLayout.HORIZONTAL
gravity = android.view.Gravity.TOP
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
@@ -198,14 +198,14 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
fun renderExpanded(expanded: Boolean) {
removeAllViews()
val metadata = metadataValues(row.entry, settings)
val contents = fieldValue(LogField.CONTENTS, row.entry, settings)
val metadata = metadataValues(entry, settings)
val contents = entry.contents
val hasContents = LogField.CONTENTS in settings.visibleFields && !contents.isNullOrEmpty()
val toggleExpanded = {
val collapsing = expanded
val collapseAnchor = if (collapsing) captureCollapseAnchor(this@apply) else null
if (!expandedIds.add(row.entry.id)) expandedIds.remove(row.entry.id)
renderExpanded(row.entry.id in expandedIds)
if (!expandedIds.add(entry.id)) expandedIds.remove(entry.id)
renderExpanded(entry.id in expandedIds)
if (collapsing) {
val highlight = { highlightCollapsedRow(this@apply) }
if (collapseAnchor != null) {
@@ -216,7 +216,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
}
Unit
}
val metadataCell = metadataCell(metadata, expanded, toggleExpanded, row.entry.id).apply {
val metadataCell = metadataCell(metadata, expanded, toggleExpanded, entry.id).apply {
layoutParams = LinearLayout.LayoutParams(
if (hasContents) metadataColumnWidth else LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
@@ -226,7 +226,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
}
if (metadata.isNotEmpty()) addView(metadataCell)
if (hasContents) {
val contentsCell = contentsCell(row, contents!!, metadata.size.coerceAtLeast(1), expanded, toggleExpanded).apply {
val contentsCell = contentsCell(entry, contents!!, metadata.size.coerceAtLeast(1), expanded, toggleExpanded).apply {
layoutParams = LinearLayout.LayoutParams(
if (metadata.isNotEmpty()) 0 else LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
@@ -237,9 +237,9 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
}
}
renderExpanded(row.entry.id in expandedIds)
renderExpanded(entry.id in expandedIds)
setOnLongClickListener {
showDeleteDialog(row.entry.id)
showDeleteDialog(entry.id)
}
}
@@ -273,7 +273,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
}
private fun contentsCell(
row: LogRow,
entry: NotificationLogEntry,
contents: String,
collapsedLines: Int,
expanded: Boolean,
@@ -290,15 +290,15 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
setLineSpacing(0f, 1f)
}
addView(message)
if (expanded && row.entry.action == NotificationAction.EDITED && !row.entry.previousContents.isNullOrEmpty()) {
if (expanded && entry.action == NotificationAction.EDITED && !entry.previousContents.isNullOrEmpty()) {
addView(previousDivider())
addView(TextView(context).apply {
text = row.entry.previousContents
text = entry.previousContents
setLineSpacing(0f, 1f)
})
}
if (expanded && row.entry.imageId != null) {
val imageId = row.entry.imageId
if (expanded && entry.imageId != null) {
val imageId = entry.imageId
val appContext = requireContext().applicationContext
val contentsContainer = this
viewLifecycleOwner.lifecycleScope.launch {
@@ -324,7 +324,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
}
}
setOnClickListener { onToggleExpanded() }
setOnLongClickListener { showDeleteDialog(row.entry.id) }
setOnLongClickListener { showDeleteDialog(entry.id) }
}
private fun previousDivider(): LinearLayout = LinearLayout(requireContext()).apply {
@@ -352,14 +352,6 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
if (LogField.ACTION in settings.visibleFields) add(MetadataValue(actionLabel(entry.action), 14f, indented = true))
}
private fun fieldValue(field: LogField, entry: NotificationLogEntry, settings: LogViewSettings): String? = when (field) {
LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings, entry.eventTimeZoneId)
LogField.APP_NAME -> entry.appName
LogField.PACKAGE_NAME -> entry.packageName
LogField.ACTION -> actionLabel(entry.action)
LogField.CONTENTS -> entry.contents
}
private fun actionLabel(action: NotificationAction): String = when (action) {
NotificationAction.APPEARED -> "Appeared"
NotificationAction.ALREADY_ACTIVE -> "Already active"
@@ -382,8 +374,8 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
.getSystemService(NotificationManager::class.java)
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
private fun metadataColumnWidth(availableWidth: Int, rows: List<LogRow>, settings: LogViewSettings): Int {
val values = rows.flatMap { metadataValues(it.entry, settings) }
private fun metadataColumnWidth(availableWidth: Int, rows: List<NotificationLogEntry>, settings: LogViewSettings): Int {
val values = rows.flatMap { metadataValues(it, settings) }
val widestTimestamp = values.filter { it.isTimestamp }
.maxOfOrNull(::metadataTextWidth)
?: 0
@@ -418,11 +410,11 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
}
private inner class LogAdapter : RecyclerView.Adapter<LogAdapter.Holder>() {
private var rows: List<LogRow> = emptyList()
private var rows: List<NotificationLogEntry> = emptyList()
private var settings = LogViewSettings()
private var metadataWidth = 0
fun submit(rows: List<LogRow>, settings: LogViewSettings, metadataWidth: Int) {
fun submit(rows: List<NotificationLogEntry>, settings: LogViewSettings, metadataWidth: Int) {
val previous = this.rows
this.rows = rows
this.settings = settings
@@ -431,7 +423,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
override fun getOldListSize() = previous.size
override fun getNewListSize() = rows.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
previous[oldItemPosition].entry.id == rows[newItemPosition].entry.id
previous[oldItemPosition].id == rows[newItemPosition].id
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
previous[oldItemPosition] == rows[newItemPosition]
}).dispatchUpdatesTo(this)
@@ -547,7 +539,6 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
resources.displayMetrics,
)
private data class LogRow(val entry: NotificationLogEntry)
private data class LoggedApp(val name: String, val packageName: String)
private data class MetadataValue(
val text: String,
@@ -567,7 +558,6 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private companion object {
const val SCROLL_TO_TOP_THRESHOLD_DP = 120
const val LOAD_MORE_THRESHOLD_DP = 480
const val PAGE_SIZE = 80
const val IMAGE_VIEWER_TAG = "image-viewer"
const val MINIMUM_METADATA_COLUMN_DP = 120
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog
package se.ajpanton.notificationsmaster
import android.content.Context
import android.graphics.Bitmap
@@ -0,0 +1,115 @@
package se.ajpanton.notificationsmaster.alerts
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import se.ajpanton.notificationsmaster.data.EncryptedAtomicFile
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
import se.ajpanton.notificationsmaster.module.AlertPolicySync
/** Atomic, encrypted storage for alert settings. An unsupported format fails closed. */
class AlertConfigurationStore(context: Context) {
private val context = context.applicationContext
private val file = EncryptedAtomicFile(this.context, FILE_NAME)
fun load(): AlertConfiguration = synchronized(lock) {
if (!file.exists) cached = null
cached ?: file.read()?.let(AlertConfigurationJson::decode)?.also { cached = it }
?: AlertConfiguration()
}
fun save(configuration: AlertConfiguration) {
synchronized(lock) {
file.write(AlertConfigurationJson.encode(configuration))
cached = configuration
}
NotificationListenerComponentController.synchronize(context)
AlertPolicySync.publish(context, configuration)
}
private companion object {
const val FILE_NAME = "alert-configuration.bin"
val lock = Any()
var cached: AlertConfiguration? = null
}
}
internal object AlertConfigurationJson {
fun encode(configuration: AlertConfiguration): ByteArray = JSONObject()
.put("version", 1)
.put("enabled", configuration.enabled)
.put("ignoreRoutineUpdates", configuration.ignoreRoutineUpdates)
.put("profiles", JSONArray().apply { configuration.profiles.forEach { put(it.toJson()) } })
.put("apps", JSONArray().apply { configuration.appSettings.forEach { put(it.toJson()) } })
.put("rules", JSONArray().apply { configuration.rules.forEach { put(it.toJson()) } })
.put("queue", JSONObject().put("maximumLength", configuration.queueSettings.maximumLength)
.put("overflow", configuration.queueSettings.overflow.name))
.toString().encodeToByteArray()
fun decode(bytes: ByteArray): AlertConfiguration {
val root = JSONObject(bytes.decodeToString())
require(root.getInt("version") == 1) { "Unsupported alert-configuration format." }
return AlertConfiguration(
enabled = root.getBoolean("enabled"),
ignoreRoutineUpdates = root.getBoolean("ignoreRoutineUpdates"),
profiles = root.getJSONArray("profiles").map { (it as JSONObject).toProfile() },
appSettings = root.getJSONArray("apps").map { (it as JSONObject).toAppSettings() },
rules = root.getJSONArray("rules").map { (it as JSONObject).toRule() },
queueSettings = root.getJSONObject("queue").let {
AlertQueueSettings(it.getInt("maximumLength"), AlertQueueOverflow.valueOf(it.getString("overflow")))
},
)
}
private fun AlertProfile.toJson() = JSONObject()
.put("id", id).put("name", name).put("soundUri", soundUri)
.put("vibrationPattern", JSONArray(vibrationPattern))
private fun AlertAppSettings.toJson() = JSONObject()
.put("packageName", packageName).put("allowMultipleMatches", allowMultipleMatches)
private fun AlertRule.toJson() = JSONObject()
.put("id", id).put("packageName", packageName).put("order", order)
.put("sources", JSONArray(sources.map { it.name }))
.put("matcher", matcher.toJson()).put("outcome", outcome.name).put("profileId", profileId)
.put("playToCompletion", playToCompletion).put("allowDuringDnd", allowDuringDnd)
.put("enabled", enabled).put("name", name)
private fun AlertTextMatcher.toJson() = JSONObject()
.put("field", field.name).put("mode", mode.name).put("value", value)
.put("caseSensitive", caseSensitive)
private fun JSONObject.toProfile() = AlertProfile(
getString("id"), getString("name"), nullableString("soundUri"),
getJSONArray("vibrationPattern").map { (it as Number).toLong() },
)
private fun JSONObject.toAppSettings() = AlertAppSettings(
getString("packageName"), getBoolean("allowMultipleMatches"),
)
private fun JSONObject.toRule() = AlertRule(
id = getString("id"),
packageName = getString("packageName"),
order = getInt("order"),
sources = getJSONArray("sources").map { AlertSource.valueOf(it as String) }.toSet(),
matcher = getJSONObject("matcher").let {
AlertTextMatcher(
AlertTextField.valueOf(it.getString("field")),
AlertTextMode.valueOf(it.getString("mode")),
it.getString("value"),
it.getBoolean("caseSensitive"),
)
},
outcome = AlertOutcome.valueOf(getString("outcome")),
profileId = nullableString("profileId"),
playToCompletion = getBoolean("playToCompletion"),
allowDuringDnd = getBoolean("allowDuringDnd"),
enabled = getBoolean("enabled"),
name = nullableString("name"),
)
private fun JSONObject.nullableString(name: String) = if (isNull(name)) null else getString(name)
private fun <T> JSONArray.map(transform: (Any) -> T) = List(length()) { transform(get(it)) }
}
@@ -0,0 +1,179 @@
package se.ajpanton.notificationsmaster.alerts
import com.google.re2j.Pattern
enum class AlertSource {
NOTIFICATION_POST,
NOTIFICATION_UPDATE,
;
fun label() = when (this) {
NOTIFICATION_POST -> "Appearing"
NOTIFICATION_UPDATE -> "Edits"
}
}
enum class AlertOutcome { PLAY_PROFILE, SILENCE_ORIGINAL, PASS_THROUGH }
enum class AlertTextField { ANY_TEXT, TITLE, BODY, SENDER }
enum class AlertTextMode { ANY, CONTAINS, REGEX }
enum class AlertQueueOverflow { DROP_OLDEST, DROP_NEWEST }
data class AlertProfile(
val id: String,
val name: String,
val soundUri: String? = null,
val vibrationPattern: List<Long> = emptyList(),
) {
init {
require(id.isNotBlank() && name.isNotBlank())
require(soundUri?.isNotBlank() != false)
require(soundUri != null || vibrationPattern.any { it > 0 })
require(vibrationPattern.all { it >= 0 })
}
}
data class AlertTextMatcher(
val field: AlertTextField = AlertTextField.ANY_TEXT,
val mode: AlertTextMode = AlertTextMode.ANY,
val value: String = "",
val caseSensitive: Boolean = false,
) {
private val pattern = if (mode == AlertTextMode.REGEX) Pattern.compile(
value,
if (caseSensitive) 0 else Pattern.CASE_INSENSITIVE,
) else null
init {
require(mode == AlertTextMode.ANY || value.isNotBlank())
}
fun matches(event: AlertEvent): Boolean = when (mode) {
AlertTextMode.ANY -> true
AlertTextMode.CONTAINS -> text(event)?.contains(value, ignoreCase = !caseSensitive) == true
AlertTextMode.REGEX -> text(event)?.let { pattern!!.matcher(it).find() } == true
}
private fun text(event: AlertEvent): String? = when (field) {
AlertTextField.TITLE -> event.title
AlertTextField.BODY -> event.body
AlertTextField.SENDER -> event.sender
AlertTextField.ANY_TEXT -> listOfNotNull(event.title, event.body, event.sender).joinToString("\n").ifBlank { null }
}
}
data class AlertRule(
val id: String,
val packageName: String,
val order: Int,
val sources: Set<AlertSource>,
val matcher: AlertTextMatcher = AlertTextMatcher(),
val outcome: AlertOutcome,
val profileId: String? = null,
val playToCompletion: Boolean = false,
val allowDuringDnd: Boolean = false,
val enabled: Boolean = true,
val name: String? = null,
) {
init {
require(id.isNotBlank() && packageName.isNotBlank() && sources.isNotEmpty())
require((outcome == AlertOutcome.PLAY_PROFILE) == (profileId != null))
require(name?.isNotBlank() != false)
}
fun groupingKey() = AlertRuleGroupingKey(
sources = sources,
matcher = matcher,
outcome = outcome,
profileId = profileId,
playToCompletion = playToCompletion,
allowDuringDnd = allowDuringDnd,
enabled = enabled,
)
}
data class AlertRuleGroupingKey(
val sources: Set<AlertSource>,
val matcher: AlertTextMatcher,
val outcome: AlertOutcome,
val profileId: String?,
val playToCompletion: Boolean,
val allowDuringDnd: Boolean,
val enabled: Boolean,
)
data class AlertAppSettings(
val packageName: String,
val allowMultipleMatches: Boolean = false,
)
data class AlertQueueSettings(
val maximumLength: Int = 3,
val overflow: AlertQueueOverflow = AlertQueueOverflow.DROP_OLDEST,
) {
init {
require(maximumLength in 1..10)
}
}
data class AlertConfiguration(
val enabled: Boolean = true,
val ignoreRoutineUpdates: Boolean = true,
val profiles: List<AlertProfile> = emptyList(),
val appSettings: List<AlertAppSettings> = emptyList(),
val rules: List<AlertRule> = emptyList(),
val queueSettings: AlertQueueSettings = AlertQueueSettings(),
) {
init {
require(profiles.map { it.id }.distinct().size == profiles.size)
require(appSettings.map { it.packageName }.distinct().size == appSettings.size)
require(rules.map { it.id }.distinct().size == rules.size)
require(rules.filter { it.outcome == AlertOutcome.PLAY_PROFILE }.all { rule ->
rule.profileId in profiles.map { it.id }
})
}
val hasEnabledRules get() = enabled && rules.any { it.enabled }
val needsListener get() = hasEnabledRules
}
data class AlertEvent(
val packageName: String,
val source: AlertSource,
val title: String? = null,
val body: String? = null,
val sender: String? = null,
)
data class AlertDecision(
val ruleId: String,
val outcome: AlertOutcome,
val profile: AlertProfile? = null,
val playToCompletion: Boolean,
val allowDuringDnd: Boolean,
)
/** Resolved at match time so queued alerts never point at mutable settings. */
data class AlertSnapshot(
val soundUri: String?,
val vibrationPattern: List<Long>,
val playToCompletion: Boolean,
val allowDuringDnd: Boolean,
) {
init {
require(soundUri != null || vibrationPattern.any { it > 0 })
}
}
fun AlertDecision.snapshot(): AlertSnapshot? = profile?.let {
AlertSnapshot(it.soundUri, it.vibrationPattern, playToCompletion, allowDuringDnd)
}
data class AlertEvaluation(
val decisions: List<AlertDecision>,
)
fun AlertEvaluation.suppressesOriginal() =
decisions.any { it.outcome != AlertOutcome.PASS_THROUGH }
@@ -0,0 +1,18 @@
package se.ajpanton.notificationsmaster.alerts
/**
* Converts a captured notification event into immutable playback work.
* Rootless operation can play matched profiles but intentionally cannot
* suppress the originating app's native effect.
*/
object AlertNotificationDispatcher {
fun dispatch(
configuration: AlertConfiguration,
event: AlertEvent,
isRoutineUpdate: Boolean,
): List<QueuedAlert> {
return AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate).decisions.mapNotNull { decision ->
decision.snapshot()?.let { QueuedAlert(decision.ruleId, it) }
}
}
}
@@ -0,0 +1,49 @@
package se.ajpanton.notificationsmaster.alerts
/** Plays one resolved effect and invokes [onFinished] when all of its effects finish. */
fun interface AlertEffectPlayer {
fun play(alert: QueuedAlert, onFinished: () -> Unit)
fun stop() {}
}
/** Serializes effect playback around the pure queue policy. */
class AlertPlaybackController(
private val effectPlayer: AlertEffectPlayer,
private val settings: () -> AlertQueueSettings,
) {
private var queue = AlertQueue()
@Synchronized
fun enqueue(alert: QueuedAlert): AlertQueueTransition {
val before = queue
return AlertQueueController.enqueue(queue, alert, settings()).also { transition ->
queue = transition.queue
playCurrentIfChanged(before.current)
}
}
@Synchronized
fun currentQueue() = queue
@Synchronized
fun stop() {
queue = AlertQueue()
effectPlayer.stop()
}
@Synchronized
private fun complete(alert: QueuedAlert) {
if (queue.current != alert) return
val before = queue
queue = AlertQueueController.completeCurrent(queue).queue
playCurrentIfChanged(before.current)
}
private fun playCurrentIfChanged(previous: QueuedAlert?) {
val current = queue.current
if (previous == current) return
effectPlayer.stop()
current?.let { alert -> effectPlayer.play(alert) { complete(alert) } }
}
}
@@ -0,0 +1,21 @@
package se.ajpanton.notificationsmaster.alerts
/** Evaluates saved alert policy without deciding how a caller will play it. */
object AlertPolicyEvaluator {
fun evaluate(
configuration: AlertConfiguration,
event: AlertEvent,
isRoutineUpdate: Boolean,
): AlertEvaluation {
if (!configuration.enabled ||
event.source == AlertSource.NOTIFICATION_UPDATE &&
isRoutineUpdate &&
configuration.ignoreRoutineUpdates
) {
return AlertEvaluation(emptyList())
}
val app = configuration.appSettings.firstOrNull { it.packageName == event.packageName }
?: AlertAppSettings(event.packageName)
return AlertRuleEvaluator.evaluate(app, configuration.rules, configuration.profiles.associateBy { it.id }, event)
}
}
@@ -0,0 +1,60 @@
package se.ajpanton.notificationsmaster.alerts
data class QueuedAlert(
val ruleId: String,
val snapshot: AlertSnapshot,
)
/** The first alert is playing; any following alerts are waiting. */
data class AlertQueue(
val alerts: List<QueuedAlert> = emptyList(),
) {
val current get() = alerts.firstOrNull()
}
data class AlertQueueTransition(
val queue: AlertQueue,
val started: QueuedAlert? = null,
val interrupted: QueuedAlert? = null,
val dropped: QueuedAlert? = null,
val completed: QueuedAlert? = null,
)
object AlertQueueController {
fun enqueue(
queue: AlertQueue,
incoming: QueuedAlert,
settings: AlertQueueSettings,
): AlertQueueTransition {
val alerts = queue.alerts
if (alerts.isEmpty()) return AlertQueueTransition(AlertQueue(listOf(incoming)), started = incoming)
val tail = alerts.last()
val appended = if (tail.snapshot.playToCompletion) alerts + incoming else alerts.dropLast(1) + incoming
val interrupted = tail.takeUnless { tail.snapshot.playToCompletion }
if (appended.size <= settings.maximumLength) {
return AlertQueueTransition(
AlertQueue(appended),
started = incoming.takeIf { tail == alerts.first() && interrupted != null },
interrupted = interrupted,
)
}
return when (settings.overflow) {
AlertQueueOverflow.DROP_NEWEST -> AlertQueueTransition(queue, dropped = incoming)
AlertQueueOverflow.DROP_OLDEST -> {
val next = appended.drop(1)
AlertQueueTransition(
AlertQueue(next),
started = next.first(),
interrupted = alerts.first(),
)
}
}
}
fun completeCurrent(queue: AlertQueue): AlertQueueTransition {
val current = queue.current ?: return AlertQueueTransition(queue)
val next = queue.alerts.drop(1)
return AlertQueueTransition(AlertQueue(next), started = next.firstOrNull(), completed = current)
}
}
@@ -0,0 +1,108 @@
package se.ajpanton.notificationsmaster.alerts
import java.util.UUID
/** All editable rule fields apart from app assignment, local order, and identity. */
data class AlertRuleDefinition(
val sources: Set<AlertSource>,
val matcher: AlertTextMatcher = AlertTextMatcher(),
val outcome: AlertOutcome,
val profileId: String? = null,
val playToCompletion: Boolean = false,
val allowDuringDnd: Boolean = false,
val enabled: Boolean = true,
val name: String? = null,
) {
init {
require(sources.isNotEmpty())
require((outcome == AlertOutcome.PLAY_PROFILE) == (profileId != null))
require(name?.isNotBlank() != false)
}
}
data class ConsolidatedAlertRule(
val definition: AlertRuleDefinition,
val ruleIds: Set<String>,
val packageNames: Set<String>,
)
object AlertRuleEditor {
fun consolidate(rules: Iterable<AlertRule>): List<ConsolidatedAlertRule> =
rules.groupBy(AlertRule::groupingKey).values.map { group ->
ConsolidatedAlertRule(
definition = group.first().definition(),
ruleIds = group.mapTo(linkedSetOf()) { it.id },
packageNames = group.mapTo(sortedSetOf()) { it.packageName },
)
}.sortedWith(compareBy<ConsolidatedAlertRule> { it.definition.name ?: "" }.thenBy { it.packageNames.first() })
fun updateConsolidated(
configuration: AlertConfiguration,
ruleIds: Set<String>,
definition: AlertRuleDefinition,
): AlertConfiguration = configuration.copy(
rules = configuration.rules.map { rule ->
if (rule.id in ruleIds) rule.withDefinition(definition) else rule
},
)
fun updateForApp(
configuration: AlertConfiguration,
ruleId: String,
definition: AlertRuleDefinition,
): AlertConfiguration = updateConsolidated(configuration, setOf(ruleId), definition)
fun addForApps(
configuration: AlertConfiguration,
packageNames: Iterable<String>,
definition: AlertRuleDefinition,
newId: () -> String = { UUID.randomUUID().toString() },
): AlertConfiguration {
val added = packageNames.distinct().map { packageName ->
AlertRule(
id = newId(),
packageName = packageName,
order = configuration.rules.filter { it.packageName == packageName }.maxOfOrNull { it.order }?.plus(1) ?: 0,
sources = definition.sources,
matcher = definition.matcher,
outcome = definition.outcome,
profileId = definition.profileId,
playToCompletion = definition.playToCompletion,
allowDuringDnd = definition.allowDuringDnd,
enabled = definition.enabled,
name = definition.name,
)
}
return configuration.copy(rules = configuration.rules + added)
}
fun reorderForApp(
configuration: AlertConfiguration,
packageName: String,
orderedRuleIds: List<String>,
): AlertConfiguration {
require(configuration.rules.filter { it.packageName == packageName }.map { it.id }.toSet() == orderedRuleIds.toSet())
val positions = orderedRuleIds.withIndex().associate { it.value to it.index }
return configuration.copy(rules = configuration.rules.map { rule ->
if (rule.packageName == packageName) rule.copy(order = positions.getValue(rule.id)) else rule
})
}
fun remove(configuration: AlertConfiguration, ruleIds: Set<String>): AlertConfiguration =
configuration.copy(rules = configuration.rules.filterNot { it.id in ruleIds })
private fun AlertRule.definition() = AlertRuleDefinition(
sources, matcher, outcome, profileId, playToCompletion, allowDuringDnd, enabled, name,
)
private fun AlertRule.withDefinition(definition: AlertRuleDefinition) = copy(
sources = definition.sources,
matcher = definition.matcher,
outcome = definition.outcome,
profileId = definition.profileId,
playToCompletion = definition.playToCompletion,
allowDuringDnd = definition.allowDuringDnd,
enabled = definition.enabled,
name = definition.name,
)
}
@@ -0,0 +1,27 @@
package se.ajpanton.notificationsmaster.alerts
object AlertRuleEvaluator {
fun evaluate(
app: AlertAppSettings,
rules: Iterable<AlertRule>,
profiles: Map<String, AlertProfile>,
event: AlertEvent,
): AlertEvaluation {
if (app.packageName != event.packageName) return AlertEvaluation(emptyList())
val matches = rules.asSequence()
.filter { it.enabled && it.packageName == event.packageName && event.source in it.sources }
.sortedWith(compareBy<AlertRule> { it.order }.thenBy { it.id })
.filter { it.matcher.matches(event) }
.mapNotNull { rule -> rule.decision(profiles[rule.profileId]) }
.let { if (app.allowMultipleMatches) it else it.take(1) }
.toList()
return AlertEvaluation(matches)
}
private fun AlertRule.decision(profile: AlertProfile?): AlertDecision? = when (outcome) {
AlertOutcome.PLAY_PROFILE -> profile?.let {
AlertDecision(id, outcome, it, playToCompletion, allowDuringDnd)
}
else -> AlertDecision(id, outcome, null, playToCompletion, allowDuringDnd)
}
}
@@ -0,0 +1,104 @@
package se.ajpanton.notificationsmaster.alerts
import android.content.Context
import android.app.NotificationManager
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.VibratorManager
import android.util.Log
/** Plays resolved notification-class effects and releases all platform resources on stop. */
class AndroidAlertEffectPlayer(
context: Context,
private val interruptionFilter: () -> Int = {
context.getSystemService(NotificationManager::class.java).currentInterruptionFilter
},
) : AlertEffectPlayer {
private val context = context.applicationContext
private val handler = Handler(Looper.getMainLooper())
private val vibrator = context.getSystemService(VibratorManager::class.java).defaultVibrator
private var generation = 0L
private var remainingEffects = 0
private var player: MediaPlayer? = null
private var completion: (() -> Unit)? = null
override fun play(alert: QueuedAlert, onFinished: () -> Unit) = synchronized(this) {
Log.i(TAG, "Playing custom notification alert")
stopLocked()
val token = generation
completion = onFinished
val snapshot = alert.snapshot
if (!DndPolicy.allowsCustomAlert(interruptionFilter())) {
Log.i(TAG, "Skipping custom notification alert during Do Not Disturb")
completion = null
onFinished()
return
}
val vibrationDuration = snapshot.vibrationPattern.sum()
remainingEffects = (if (snapshot.soundUri == null) 0 else 1) + (if (vibrationDuration > 0) 1 else 0)
snapshot.soundUri?.let { startSound(Uri.parse(it), token) }
if (vibrationDuration > 0) {
vibrator.vibrate(
VibrationEffect.createWaveform(snapshot.vibrationPattern.toLongArray(), -1),
VibrationAttributes.Builder().setUsage(VibrationAttributes.USAGE_NOTIFICATION).build(),
)
handler.postDelayed({ finishEffect(token) }, vibrationDuration)
}
if (remainingEffects == 0) finishEffect(token)
}
override fun stop() = synchronized(this) { stopLocked() }
private fun startSound(uri: Uri, token: Long) {
val mediaPlayer = MediaPlayer()
try {
player = mediaPlayer
mediaPlayer.setAudioAttributes(
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION).build(),
)
mediaPlayer.setDataSource(context, uri)
mediaPlayer.setOnCompletionListener { finishSound(token, mediaPlayer) }
mediaPlayer.setOnErrorListener { _, _, _ ->
finishSound(token, mediaPlayer)
true
}
mediaPlayer.prepareAsync()
} catch (_: Exception) {
if (player === mediaPlayer) player = null
mediaPlayer.release()
finishEffect(token)
}
}
private fun finishSound(token: Long, mediaPlayer: MediaPlayer): Unit = synchronized(this) {
if (player !== mediaPlayer) return
player = null
mediaPlayer.release()
finishEffect(token)
}
private fun finishEffect(token: Long): Unit = synchronized(this) {
if (token != generation || remainingEffects == 0) return
if (--remainingEffects != 0) return
completion.also { completion = null }?.invoke()
}
private fun stopLocked() {
generation++
remainingEffects = 0
completion = null
handler.removeCallbacksAndMessages(null)
player?.release()
player = null
vibrator.cancel()
}
private companion object {
const val TAG = "NotificationsMaster"
}
}
@@ -0,0 +1,9 @@
package se.ajpanton.notificationsmaster.alerts
import android.app.NotificationManager
/** Direct custom effects must not bypass the user's Do Not Disturb choice. */
internal object DndPolicy {
fun allowsCustomAlert(interruptionFilter: Int) =
interruptionFilter == NotificationManager.INTERRUPTION_FILTER_ALL
}
@@ -1,6 +1,6 @@
package se.ajpanton.notificationlog.capture
package se.ajpanton.notificationsmaster.capture
import se.ajpanton.notificationlog.model.NotificationAction
import se.ajpanton.notificationsmaster.model.NotificationAction
/** Applies the global, Android-provided group-summary classification without inspecting text. */
object GroupSummaryPolicy {
@@ -1,20 +1,30 @@
package se.ajpanton.notificationlog.capture
package se.ajpanton.notificationsmaster.capture
import android.app.Notification
import android.content.Intent
import android.content.pm.PackageManager
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.model.NotificationAction
import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.NotificationRuleEvaluator
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.alerts.AlertEvent
import se.ajpanton.notificationsmaster.alerts.AlertNotificationDispatcher
import se.ajpanton.notificationsmaster.alerts.AlertPlaybackController
import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings
import se.ajpanton.notificationsmaster.alerts.AlertSource
import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer
import se.ajpanton.notificationsmaster.module.AlertPolicySync
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
import se.ajpanton.notificationsmaster.debug.DebugNotifications
import se.ajpanton.notificationsmaster.model.NotificationAction
import se.ajpanton.notificationsmaster.model.NotificationLogEntry
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
import se.ajpanton.notificationsmaster.settings.LoggingType
import se.ajpanton.notificationsmaster.settings.NotificationRuleEvaluator
import se.ajpanton.notificationsmaster.settings.CaptureSettingsStore
import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@@ -27,6 +37,9 @@ class NotificationCaptureService : NotificationListenerService() {
private lateinit var appFilterStore: AppFilterSettingsStore
private lateinit var perAppEventSettings: PerAppEventSettingsStore
private lateinit var imageStore: EncryptedImageStore
private lateinit var alertConfigurationStore: AlertConfigurationStore
private lateinit var alertPlayback: AlertPlaybackController
private var alertQueueSettings = AlertQueueSettings()
override fun onCreate() {
super.onCreate()
@@ -36,6 +49,8 @@ class NotificationCaptureService : NotificationListenerService() {
appFilterStore = AppFilterSettingsStore(this)
perAppEventSettings = PerAppEventSettingsStore(this)
imageStore = EncryptedImageStore(this)
alertConfigurationStore = AlertConfigurationStore(this)
alertPlayback = AlertPlaybackController(AndroidAlertEffectPlayer(this)) { alertQueueSettings }
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "notification-log-writer")
}
@@ -43,9 +58,11 @@ class NotificationCaptureService : NotificationListenerService() {
override fun onListenerConnected() {
super.onListenerConnected()
AlertPolicySync.publishPlaybackState(this, true)
getActiveNotifications()?.forEach { sbn ->
if (DebugNotifications.isManaged(this, sbn)) return@forEach
val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName)
SeenApps.markSeen(this, snapshot.packageName)
activeNotifications[snapshot.key] = snapshot
record(
snapshot,
@@ -58,19 +75,23 @@ class NotificationCaptureService : NotificationListenerService() {
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
if (DebugNotifications.isManaged(this, sbn)) return
val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName)
SeenApps.markSeen(this, snapshot.packageName)
val previous = activeNotifications.put(snapshot.key, snapshot)
when {
previous == null -> record(
previous == null -> {
record(
snapshot,
NotificationAction.APPEARED,
LoggingType.APPEARING,
includeContents = true,
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
)
NotificationChangeClassifier.isMeaningfulEdit(previous, snapshot) &&
!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates) ->
dispatchAlert(snapshot, AlertSource.NOTIFICATION_POST)
}
NotificationChangeClassifier.isMeaningfulEdit(previous, snapshot) -> {
if (!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates)) {
record(
snapshot,
NotificationAction.EDITED,
@@ -80,6 +101,9 @@ class NotificationCaptureService : NotificationListenerService() {
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
)
}
dispatchAlert(snapshot, AlertSource.NOTIFICATION_UPDATE)
}
}
}
override fun onNotificationRemoved(
@@ -87,12 +111,15 @@ class NotificationCaptureService : NotificationListenerService() {
rankingMap: RankingMap,
reason: Int,
) {
if (DebugNotifications.isManaged(this, sbn)) return
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName)
SeenApps.markSeen(this, snapshot.packageName)
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false)
}
override fun onDestroy() {
AlertPolicySync.publishPlaybackState(this, false)
alertPlayback.stop()
writeExecutor.shutdown()
super.onDestroy()
}
@@ -106,9 +133,13 @@ class NotificationCaptureService : NotificationListenerService() {
imageBytes: (() -> ByteArray?)? = null,
) {
if (!GroupSummaryPolicy.shouldLog(snapshot, action, captureSettings.logGroupSummaries)) return
if (!allows(loggingType, snapshot.packageName)) return
if (!loggingEnabled(loggingType, snapshot.packageName) ||
!NotificationRuleEvaluator.allows(appFilterStore.load(), snapshot.packageName)
) return
val appName = appName(snapshot.packageName)
val retainImage = includeContents && snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
val textEnabled = includeContents && loggingEnabled(LoggingType.TEXT_CONTENT, snapshot.packageName)
val imageEnabled = includeContents && loggingEnabled(LoggingType.IMAGE_CONTENT, snapshot.packageName)
val retainImage = snapshot.hasImage && imageEnabled
val retainedImageBytes = if (retainImage) imageBytes?.invoke() else null
val entry = NotificationLogEntry(
recordedAtEpochMillis = System.currentTimeMillis(),
@@ -116,8 +147,8 @@ class NotificationCaptureService : NotificationListenerService() {
packageName = snapshot.packageName,
appName = appName,
action = action,
contents = if (includeContents) visibleContents(snapshot) else null,
previousContents = previousSnapshot?.let(::visibleContents),
contents = if (includeContents) visibleContents(snapshot, textEnabled, imageEnabled) else null,
previousContents = previousSnapshot?.let { visibleContents(it, textEnabled, imageEnabled) },
imageId = if (retainedImageBytes != null) java.util.UUID.randomUUID().toString() else null,
)
writeExecutor.execute {
@@ -139,25 +170,33 @@ class NotificationCaptureService : NotificationListenerService() {
}
}
private fun visibleContents(snapshot: NotificationSnapshot): String? {
val text = snapshot.textContents?.takeIf {
allows(LoggingType.TEXT_CONTENT, snapshot.packageName)
}
val image = snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
return listOfNotNull(text, if (image) "[image]" else null)
private fun visibleContents(snapshot: NotificationSnapshot, textEnabled: Boolean, imageEnabled: Boolean): String? {
return listOfNotNull(
snapshot.textContents?.takeIf { textEnabled },
if (snapshot.hasImage && imageEnabled) "[image]" else null,
)
.joinToString("\n")
.take(MAX_CONTENT_CHARACTERS)
.ifEmpty { null }
}
private fun allows(type: LoggingType, packageName: String): Boolean {
private fun loggingEnabled(type: LoggingType, packageName: String): Boolean {
val globalEnabled = ruleStore.ruleFor(type).enabled
val eventEnabled = if (type in LoggingType.eventTypes) {
return if (type in LoggingType.eventTypes) {
perAppEventSettings.isEnabled(packageName, type, globalEnabled)
} else {
globalEnabled
}
return eventEnabled && NotificationRuleEvaluator.allows(appFilterStore.load(), packageName)
}
private fun dispatchAlert(snapshot: NotificationSnapshot, source: AlertSource) {
val configuration = alertConfigurationStore.load()
alertQueueSettings = configuration.queueSettings
AlertNotificationDispatcher.dispatch(
configuration,
AlertEvent(snapshot.packageName, source, snapshot.alertTitle, snapshot.alertBody, snapshot.alertSender),
isRoutineUpdate = source == AlertSource.NOTIFICATION_UPDATE && snapshot.isRoutine,
).forEach(alertPlayback::enqueue)
}
private fun appName(packageName: String): String = try {
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.capture
package se.ajpanton.notificationsmaster.capture
/** Keeps update classification deterministic and independently testable. */
object NotificationChangeClassifier {
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.capture
package se.ajpanton.notificationsmaster.capture
import android.app.Notification
import android.content.Context
@@ -19,9 +19,18 @@ data class NotificationSnapshot(
val hasImage: Boolean,
val isGroupSummary: Boolean,
val isRoutine: Boolean,
val alertTitle: String? = null,
val alertBody: String? = null,
val alertSender: String? = null,
)
object NotificationContents {
data class AlertTextContents(
val title: String?,
val body: String?,
val sender: String?,
)
fun extract(notification: Notification): String? {
val extras = notification.extras ?: Bundle.EMPTY
messagingContents(extras)?.let { return it }
@@ -69,6 +78,7 @@ object NotificationContents {
fun snapshot(sbn: StatusBarNotification): NotificationSnapshot {
val notification = sbn.notification
val extras = notification.extras
val alertContents = alertContents(notification)
return NotificationSnapshot(
key = sbn.key,
packageName = sbn.packageName,
@@ -83,9 +93,24 @@ object NotificationContents {
it.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0 &&
it.getInt(Notification.EXTRA_PROGRESS, -1) >= 0
} == true,
alertTitle = alertContents.title,
alertBody = alertContents.body,
alertSender = alertContents.sender,
)
}
fun alertContents(notification: Notification): AlertTextContents {
val extras = notification.extras ?: Bundle.EMPTY
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
val body = extract(notification)?.let { contents ->
title?.let { contents.removePrefix(it + "\n") }?.ifBlank { null } ?: contents
}
val sender = Notification.MessagingStyle.Message.getMessagesFromBundleArray(
extras.getParcelableArray(Notification.EXTRA_MESSAGES, Bundle::class.java),
).lastOrNull()?.senderPerson?.name?.toString()
return AlertTextContents(title, body, sender)
}
/** Called only after the event has passed all capture filters. */
fun extractImageBytes(notification: Notification, context: Context): ByteArray? {
val extras = notification.extras ?: return null
@@ -0,0 +1,39 @@
package se.ajpanton.notificationsmaster.capture
import android.content.Context
import android.os.SystemClock
import kotlin.math.abs
/** Persists packages seen by the listener for the duration of the current boot. */
object SeenApps {
@Synchronized
fun markSeen(context: Context, packageName: String) {
val preferences = preferences(context)
resetAfterBoot(preferences)
val packages = preferences.getStringSet(KEY_PACKAGES, emptySet()).orEmpty()
if (packageName !in packages) {
preferences.edit().putStringSet(KEY_PACKAGES, packages + packageName).apply()
}
}
@Synchronized
fun snapshot(context: Context): Set<String> {
val preferences = preferences(context)
resetAfterBoot(preferences)
return preferences.getStringSet(KEY_PACKAGES, emptySet()).orEmpty().toSet()
}
private fun resetAfterBoot(preferences: android.content.SharedPreferences) {
val bootEpoch = System.currentTimeMillis() - SystemClock.elapsedRealtime()
if (abs(preferences.getLong(KEY_BOOT_EPOCH, -1) - bootEpoch) > 5_000) {
preferences.edit().putLong(KEY_BOOT_EPOCH, bootEpoch).remove(KEY_PACKAGES).apply()
}
}
private fun preferences(context: Context) =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
private const val PREFERENCES = "seen_notification_apps"
private const val KEY_PACKAGES = "packages"
private const val KEY_BOOT_EPOCH = "boot_epoch"
}
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.data
package se.ajpanton.notificationsmaster.data
import javax.crypto.Cipher
import javax.crypto.SecretKey
@@ -0,0 +1,56 @@
package se.ajpanton.notificationsmaster.data
import android.content.Context
import android.util.AtomicFile
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileNotFoundException
internal class EncryptedAtomicFile(context: Context, name: String) {
private val file = AtomicFile(File(context.filesDir, name))
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
val exists get() = file.baseFile.isFile
fun read(): ByteArray? = try {
DataInputStream(BufferedInputStream(file.openRead())).use { input ->
val iv = input.readBytes(MAX_IV_BYTES)
val encrypted = input.readBytes(MAX_CIPHER_TEXT_BYTES)
cipher.decrypt(EncryptedPayload(iv, encrypted))
}
} catch (_: FileNotFoundException) {
null
}
fun write(bytes: ByteArray) {
val payload = cipher.encrypt(bytes)
val output = file.startWrite()
try {
DataOutputStream(BufferedOutputStream(output)).apply {
writeInt(payload.initializationVector.size)
write(payload.initializationVector)
writeInt(payload.cipherText.size)
write(payload.cipherText)
flush()
}
file.finishWrite(output)
} catch (error: Exception) {
file.failWrite(output)
throw error
}
}
private fun DataInputStream.readBytes(maximum: Int): ByteArray {
val length = readInt()
require(length in 1..maximum) { "Invalid encrypted configuration field length." }
return ByteArray(length).also(::readFully)
}
private companion object {
const val MAX_IV_BYTES = 32
const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024
}
}
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.data
package se.ajpanton.notificationsmaster.data
import android.content.Context
import android.util.AtomicFile
@@ -1,9 +1,9 @@
package se.ajpanton.notificationlog.data
package se.ajpanton.notificationsmaster.data
import android.content.Context
import android.util.AtomicFile
import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.StorageLimitsStore
import se.ajpanton.notificationsmaster.model.NotificationLogEntry
import se.ajpanton.notificationsmaster.settings.StorageLimitsStore
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
@@ -80,10 +80,11 @@ class EncryptedNotificationLogStore(context: Context) {
val chunks = chunkFiles()
val newest = chunks.lastOrNull()
val current = newest?.let(::readChunk).orEmpty()
if (newest == null || encodedSize(current + entry) > MAX_CHUNK_PLAINTEXT_BYTES) {
writeChunk(nextChunkFile(chunks), listOf(entry))
val combined = NotificationLogEntryJson.encode(current + entry)
if (newest == null || combined.size <= MAX_CHUNK_PLAINTEXT_BYTES) {
writeChunk(newest ?: nextChunkFile(chunks), combined)
} else {
writeChunk(newest, current + entry)
writeChunk(nextChunkFile(chunks), NotificationLogEntryJson.encode(listOf(entry)))
}
enforceLimits()
}
@@ -98,7 +99,7 @@ class EncryptedNotificationLogStore(context: Context) {
val entries = readChunk(chunk)
val entry = entries.firstOrNull { it.id == id } ?: return@forEach
val retained = entries.filterNot { it.id == id }
if (retained.isEmpty()) chunk.delete() else writeChunk(chunk, retained)
if (retained.isEmpty()) chunk.delete() else writeChunk(chunk, NotificationLogEntryJson.encode(retained))
entry.imageId?.let(::deleteImage)
return true
}
@@ -137,7 +138,8 @@ class EncryptedNotificationLogStore(context: Context) {
while (totalBytes > limit && retained.isNotEmpty()) {
retained.first().imageId?.let(::deleteImage)
retained = retained.drop(1)
if (retained.isEmpty()) oldest.delete() else writeChunk(oldest, retained)
if (retained.isEmpty()) oldest.delete()
else writeChunk(oldest, NotificationLogEntryJson.encode(retained))
totalBytes = chunkFiles().sumOf(File::length)
}
}
@@ -203,8 +205,8 @@ class EncryptedNotificationLogStore(context: Context) {
}
}
private fun writeChunk(file: File, entries: List<NotificationLogEntry>) {
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
private fun writeChunk(file: File, encoded: ByteArray) {
val payload = cipher.encrypt(encoded)
val atomicFile = AtomicFile(file)
val output = atomicFile.startWrite()
try {
@@ -228,8 +230,6 @@ class EncryptedNotificationLogStore(context: Context) {
return ByteArray(length).also(stream::readFully)
}
private fun encodedSize(entries: List<NotificationLogEntry>) = NotificationLogEntryJson.encode(entries).size
private fun clearChunksOnly() {
chunkFiles().forEach(File::delete)
}
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.data
package se.ajpanton.notificationsmaster.data
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
@@ -1,9 +1,9 @@
package se.ajpanton.notificationlog.data
package se.ajpanton.notificationsmaster.data
import org.json.JSONArray
import org.json.JSONObject
import se.ajpanton.notificationlog.model.NotificationAction
import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationsmaster.model.NotificationAction
import se.ajpanton.notificationsmaster.model.NotificationLogEntry
/** Versioned, private plaintext representation which is encrypted before it reaches disk. */
object NotificationLogEntryJson {
@@ -0,0 +1,214 @@
package se.ajpanton.notificationsmaster.debug
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.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.Handler
import android.os.Looper
import android.os.SystemClock
import android.service.notification.StatusBarNotification
import se.ajpanton.notificationsmaster.MainActivity
import kotlin.math.abs
/** Posts real, silent notifications for testing icon filtering and layout. */
object DebugNotifications {
const val EXTRA_DEBUG = "se.ajpanton.notificationsmaster.DEBUG_NOTIFICATION"
const val MAX_COUNT = 50
private const val PREFS = "debug_notifications"
private const val KEY_COUNT = "count"
private const val KEY_CYCLE = "cycle"
private const val KEY_CURRENT = "current"
private const val KEY_DIRECTION = "direction"
private const val KEY_BOOT_EPOCH = "boot_epoch"
private const val KEY_RECOVERY_USED = "recovery_used"
private const val CHANNEL_ID = "debug_notification_icons"
private const val ID_BASE = 20_000
private const val GROUP_PREFIX = "notifications_master_debug_"
private const val CYCLE_DELAY = 2_000L
private const val RECOVERY_DELAY = 3_500L
private val handler = Handler(Looper.getMainLooper())
private var cycleTask: Runnable? = null
private var recoveryTask: Runnable? = null
fun count(context: Context) = prefs(context).getInt(KEY_COUNT, 0).coerceIn(0, MAX_COUNT)
fun isCycling(context: Context) = prefs(context).getBoolean(KEY_CYCLE, false)
fun hasPermission(context: Context) =
context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
fun setCount(context: Context, count: Int) {
prefs(context).edit()
.putInt(KEY_COUNT, count.coerceIn(0, MAX_COUNT))
.putBoolean(KEY_RECOVERY_USED, false)
.apply()
apply(context)
}
fun setCycling(context: Context, enabled: Boolean) {
prefs(context).edit()
.putBoolean(KEY_CYCLE, enabled)
.putInt(KEY_CURRENT, 0)
.putInt(KEY_DIRECTION, 1)
.putBoolean(KEY_RECOVERY_USED, false)
.apply()
apply(context)
}
fun apply(context: Context) {
val app = context.applicationContext
val prefs = prefs(app)
resetRecoveryAfterBoot(prefs)
val configured = count(app)
val desired = if (isCycling(app)) prefs.getInt(KEY_CURRENT, 0).coerceIn(0, configured) else configured
val manager = app.getSystemService(NotificationManager::class.java)
cancelAbove(app, manager, desired)
if (hasPermission(app)) {
ensureChannel(manager)
repeat(desired) { manager.notify(ID_BASE + it + 1, notification(app, it + 1)) }
scheduleRecovery(app, desired)
}
scheduleCycle(app, configured)
}
fun isDebug(notification: Notification) = notification.extras?.getBoolean(EXTRA_DEBUG, false) == true
fun isManaged(context: Context, sbn: StatusBarNotification) =
sbn.packageName == context.packageName && (
isDebug(sbn.notification) || sbn.id in (ID_BASE + 1)..(ID_BASE + MAX_COUNT) ||
(count(context) > 0 && sbn.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0)
)
private fun notification(context: Context, number: Int): Notification {
val intent = Intent(context, MainActivity::class.java)
val pending = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(numberIcon(context, number))
.setContentTitle("Debug icon $number")
.setContentText("Notifications Master")
.setContentIntent(pending)
.setShowWhen(false)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(Notification.CATEGORY_STATUS)
.setGroup("$GROUP_PREFIX$number")
.setSortKey(number.toString().padStart(2, '0'))
.setShortcutId("$GROUP_PREFIX$number")
.addExtras(android.os.Bundle().apply {
putBoolean(EXTRA_DEBUG, true)
putInt("number", number)
})
.build()
.apply { flags = flags or Notification.FLAG_NO_CLEAR or Notification.FLAG_ONGOING_EVENT }
}
private fun numberIcon(context: Context, number: Int): Icon {
val size = (48 * context.resources.displayMetrics.density + 0.5f).toInt()
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val background = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.HSVToColor(floatArrayOf(number * 37f % 360f, 0.6f, 0.9f))
}
canvas.drawRect(0f, 0f, size.toFloat(), size.toFloat(), background)
val label = number.toString()
val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textAlign = Paint.Align.CENTER
isFakeBoldText = true
textSize = size * if (label.length > 1) 0.45f else 0.6f
}
val bounds = Rect().also { text.getTextBounds(label, 0, label.length, it) }
canvas.drawText(label, size / 2f, size / 2f - bounds.exactCenterY(), text)
return Icon.createWithBitmap(bitmap)
}
private fun ensureChannel(manager: NotificationManager) {
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Debug notification icons", NotificationManager.IMPORTANCE_LOW).apply {
description = "Silent notification icons used to test layouts"
setShowBadge(false)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
},
)
}
private fun cancelAbove(context: Context, manager: NotificationManager, desired: Int) {
manager.activeNotifications.filter { it.packageName == context.packageName }.forEach { sbn ->
val number = sbn.id - ID_BASE
val autoSummary = !isDebug(sbn.notification) &&
sbn.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0
if (autoSummary || number in 1..MAX_COUNT && number > desired) cancel(manager, sbn)
}
}
private fun scheduleCycle(context: Context, configured: Int) {
if (!isCycling(context) || configured == 0) {
cycleTask?.let(handler::removeCallbacks)
cycleTask = null
return
}
if (cycleTask != null) return
cycleTask = object : Runnable {
override fun run() {
val prefs = prefs(context)
if (!prefs.getBoolean(KEY_CYCLE, false)) {
cycleTask = null
return
}
val max = count(context)
var current = prefs.getInt(KEY_CURRENT, 0).coerceIn(0, max)
var direction = if (prefs.getInt(KEY_DIRECTION, 1) < 0) -1 else 1
current += direction
if (current >= max) { current = max; direction = -1 }
if (current <= 0) { current = 0; direction = 1 }
prefs.edit().putInt(KEY_CURRENT, current).putInt(KEY_DIRECTION, direction).apply()
apply(context)
handler.postDelayed(this, CYCLE_DELAY)
}
}.also { handler.postDelayed(it, CYCLE_DELAY) }
}
private fun scheduleRecovery(context: Context, desired: Int) {
recoveryTask?.let(handler::removeCallbacks)
recoveryTask = null
if (desired == 0 || prefs(context).getBoolean(KEY_RECOVERY_USED, false)) return
recoveryTask = Runnable {
val manager = context.getSystemService(NotificationManager::class.java)
val own = manager.activeNotifications.filter { it.packageName == context.packageName }
if (own.any { it.id !in (ID_BASE + 1)..(ID_BASE + MAX_COUNT) && !isDebug(it.notification) }) {
prefs(context).edit().putBoolean(KEY_RECOVERY_USED, true).apply()
own.forEach { cancel(manager, it) }
repeat(desired) { manager.notify(ID_BASE + it + 1, notification(context, it + 1)) }
}
}.also { handler.postDelayed(it, RECOVERY_DELAY) }
}
private fun resetRecoveryAfterBoot(prefs: android.content.SharedPreferences) {
val bootEpoch = System.currentTimeMillis() - SystemClock.elapsedRealtime()
if (abs(prefs.getLong(KEY_BOOT_EPOCH, -1) - bootEpoch) > 5_000) {
prefs.edit().putLong(KEY_BOOT_EPOCH, bootEpoch).putBoolean(KEY_RECOVERY_USED, false).apply()
}
}
private fun cancel(manager: NotificationManager, sbn: StatusBarNotification) {
sbn.tag?.let { manager.cancel(it, sbn.id) } ?: manager.cancel(sbn.id)
}
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
@@ -1,10 +1,10 @@
package se.ajpanton.notificationlog.export
package se.ajpanton.notificationsmaster.export
import android.content.Context
import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettings
import se.ajpanton.notificationlog.settings.TimestampFormatter
import se.ajpanton.notificationsmaster.model.NotificationLogEntry
import se.ajpanton.notificationsmaster.settings.LogField
import se.ajpanton.notificationsmaster.settings.LogViewSettings
import se.ajpanton.notificationsmaster.settings.TimestampFormatter
object LogExporter {
fun csv(entries: List<NotificationLogEntry>, settings: LogViewSettings, context: Context? = null): String = (
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.model
package se.ajpanton.notificationsmaster.model
import java.util.UUID
@@ -0,0 +1,56 @@
package se.ajpanton.notificationsmaster.module
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import se.ajpanton.notificationsmaster.alerts.AlertConfiguration
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationJson
/**
* Sends a bounded, one-way policy snapshot to the optional system-server
* module. Android enforces this APK's signature permission, and the broadcast
* is targeted at Android's system process rather than being visible to apps.
*/
internal object AlertPolicySync {
const val ACTION = "se.ajpanton.notificationsmaster.UPDATE_ALERT_POLICY"
const val PLAYBACK_STATE_ACTION = "se.ajpanton.notificationsmaster.UPDATE_ALERT_PLAYBACK_STATE"
const val EXTRA_CONFIGURATION = "configuration"
const val EXTRA_PLAYBACK_READY = "playback_ready"
const val PERMISSION = "se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY"
private const val SYSTEM_PACKAGE = "android"
private const val MAX_CONFIGURATION_BYTES = 64 * 1024
fun publish(context: Context, configuration: AlertConfiguration) {
val payload = AlertConfigurationJson.encode(configuration)
if (payload.size > MAX_CONFIGURATION_BYTES) {
Log.w(TAG, "Alert policy is too large for the system cache; leaving native alerts unchanged")
return
}
context.sendBroadcast(
Intent(ACTION)
.setPackage(SYSTEM_PACKAGE)
.putExtra(EXTRA_CONFIGURATION, payload),
)
Log.i(TAG, "Published alert policy snapshot")
}
fun publishPlaybackState(context: Context, ready: Boolean) {
context.sendBroadcast(
Intent(PLAYBACK_STATE_ACTION)
.setPackage(SYSTEM_PACKAGE)
.putExtra(EXTRA_PLAYBACK_READY, ready),
)
}
private const val TAG = "NotificationsMaster"
}
/** Re-publishes process-local policies after boot, without starting a service. */
class AlertPolicyBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED != intent.action) return
AlertPolicySync.publish(context, se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore(context).load())
VisibilityPolicySync.publishStored(context)
}
}
@@ -0,0 +1,102 @@
package se.ajpanton.notificationsmaster.module
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.service.notification.StatusBarNotification
import android.util.Log
import io.github.libxposed.api.XposedInterface
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import java.lang.reflect.Method
import java.util.Collections
import java.util.WeakHashMap
import java.util.concurrent.ConcurrentHashMap
/** API 34 uses distinct mapper IDs for its unlocked and AOD icon containers. */
internal class Aosp34IconBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend {
private val controllers = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
private lateinit var updateStatusBarIcons: Method
private var updateAodNotificationIcons: Method? = null
private val blockedSurfaces = ConcurrentHashMap.newKeySet<NotificationSurface>()
override fun install(classLoader: ClassLoader): Boolean {
if (Build.VERSION.SDK_INT != 34) return false
return runCatching {
val controller = Class.forName(CONTROLLER, false, classLoader)
val mapper = Class.forName(ICON_MAPPER, false, classLoader)
val entry = Class.forName(NOTIFICATION_ENTRY, false, classLoader)
val classId = mapper.getDeclaredField("\$r8\$classId").also {
require(it.type == Int::class.javaPrimitiveType)
it.isAccessible = true
}
val sbn = entry.getDeclaredField("mSbn").also {
require(it.type == StatusBarNotification::class.java)
it.isAccessible = true
}
val apply = mapper.declaredMethods.singleOrNull {
it.name == "apply" && it.parameterTypes.contentEquals(arrayOf(Any::class.java))
} ?: error("Unlocked icon mapper method not found")
updateStatusBarIcons = controller.declaredMethods.singleOrNull {
it.name == "updateStatusBarIcons" && it.parameterTypes.isEmpty() && it.returnType == Void.TYPE
} ?: error("Unlocked icon refresh method not found")
updateAodNotificationIcons = controller.declaredMethods.singleOrNull {
it.name == "updateAodNotificationIcons" && it.parameterTypes.isEmpty() && it.returnType == Void.TYPE
}
apply.isAccessible = true
updateStatusBarIcons.isAccessible = true
updateAodNotificationIcons?.isAccessible = true
listOfNotNull(updateStatusBarIcons, updateAodNotificationIcons).forEach { refresh ->
framework.hook(refresh).intercept { chain ->
synchronized(controllers) { controllers += chain.thisObject }
chain.proceed()
}
}
framework.hook(apply).intercept { chain ->
val surface = when (classId.getInt(chain.thisObject)) {
AOD_CLASS_ID -> if (updateAodNotificationIcons != null) {
NotificationSurface.AOD
} else return@intercept chain.proceed()
STATUS_BAR_CLASS_ID -> NotificationSurface.UNLOCKED_STATUSBAR
else -> return@intercept chain.proceed()
}
val notification = runCatching {
VisibilityNotificationExtractor.from(sbn.get(chain.args[0]) as StatusBarNotification)
}.getOrNull() ?: return@intercept chain.proceed()
if (!ProcessVisibilityPolicyCache.isBlocked(
notification,
surface,
)) return@intercept chain.proceed()
if (blockedSurfaces.add(surface)) {
Log.i(TAG, "Filtered an API 34 ${surface.name} notification icon")
}
null
}
}.onSuccess {
Log.i(TAG, "Installed AOSP API 34 unlocked notification-icon backend")
}.onFailure {
Log.w(TAG, "AOSP API 34 unlocked icon shape is unsupported; leaving icons unchanged", it)
}.isSuccess
}
override fun onPolicyChanged() {
blockedSurfaces.clear()
Handler(Looper.getMainLooper()).post {
val current = synchronized(controllers) { controllers.toList() }
current.forEach { controller ->
runCatching {
updateStatusBarIcons.invoke(controller)
updateAodNotificationIcons?.invoke(controller)
}.onFailure { Log.w(TAG, "Could not refresh API 34 notification icons", it) }
}
}
}
private companion object {
const val TAG = "NotificationsMaster"
const val AOD_CLASS_ID = 0
const val STATUS_BAR_CLASS_ID = 1
const val CONTROLLER = "com.android.systemui.statusbar.phone.NotificationIconAreaController"
const val ICON_MAPPER = "$CONTROLLER\$\$ExternalSyntheticLambda2"
const val NOTIFICATION_ENTRY = "com.android.systemui.statusbar.notification.collection.NotificationEntry"
}
}
@@ -0,0 +1,294 @@
package se.ajpanton.notificationsmaster.module
import android.os.Build
import android.service.notification.StatusBarNotification
import android.util.Log
import android.view.View
import android.view.ViewGroup
import io.github.libxposed.api.XposedInterface
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.util.Collections
import java.util.WeakHashMap
/** Filters AOSP notification rows for the collapsed lockscreen or notification drawer. */
internal class AospLockscreenBackend(
private val framework: XposedInterface,
) : VisibilitySurfaceBackend {
private val stacks = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
private val shelves = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
private val hiddenRows = WeakHashMap<View, RowState>()
private val groupOrders = WeakHashMap<View, List<View>>()
private lateinit var onKeyguard: Method
private var stackExpanded: Field? = null
private lateinit var entryForRow: (View) -> Any?
private lateinit var sbnForEntry: (Any) -> StatusBarNotification?
private lateinit var attachedChildrenForRow: (View) -> List<View>
private lateinit var reorderChildren: (View, List<View>) -> Unit
private lateinit var setGroupCount: (View, Int) -> Unit
private lateinit var calculateShelfIcons: Method
private lateinit var shelfNotification: Field
private lateinit var shelfIconStates: Field
private lateinit var iconState: IconStateAccess
@Volatile private var blockedObserved = false
override fun install(classLoader: ClassLoader): Boolean {
if (Build.MANUFACTURER.equals("samsung", ignoreCase = true)) return false
return runCatching {
val stackClass = Class.forName(STACK, false, classLoader)
val rowClass = Class.forName(ROW, false, classLoader)
onKeyguard = stackClass.declaredMethods.singleOrNull {
it.name == "onKeyguard" && it.parameterCount == 0 &&
it.returnType == Boolean::class.javaPrimitiveType
}?.also { it.isAccessible = true }
?: error("AOSP lockscreen state method not found")
stackExpanded = findField(stackClass, "mIsExpanded")
prepareNotificationAccess(rowClass)
installShelfFilter(classLoader)
stackClass.declaredMethods.filter { it.name == "onViewAdded" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
val result = chain.proceed()
(chain.thisObject as? ViewGroup)?.let { stack ->
track(stack)
(chain.args.firstOrNull() as? View)?.let { applyRow(stack, it) }
}
result
}
}
stackClass.declaredMethods.filter { it.name == "onViewRemoved" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
(chain.args.firstOrNull() as? View)?.let(::restoreTree)
chain.proceed()
}
}
stackClass.declaredMethods.filter { it.name == "onMeasure" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
(chain.thisObject as? ViewGroup)?.let {
track(it)
applyStack(it)
}
chain.proceed()
}
}
stackClass.declaredMethods.filter {
it.name == "setStatusBarState" || it.name == "setIsExpanded"
}.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
val result = chain.proceed()
(chain.thisObject as? ViewGroup)?.let {
track(it)
applyStack(it)
}
result
}
}
}.onSuccess {
Log.i(TAG, "Installed AOSP lockscreen/drawer notification backend")
}.onFailure {
Log.w(TAG, "AOSP lockscreen shape is unsupported; leaving rows unchanged", it)
}.isSuccess
}
override fun onPolicyChanged() {
blockedObserved = false
synchronized(stacks) { stacks.toList() }.forEach(::applyStack)
synchronized(shelves) { shelves.toList() }.forEach { shelf ->
runCatching { calculateShelfIcons.invoke(shelf) }
.onFailure { Log.w(TAG, "Could not refresh AOSP lockscreen shelf", it) }
}
}
private fun prepareNotificationAccess(rowClass: Class<*>) {
val entryMethod = rowClass.methods.firstOrNull { it.name == "getEntry" && it.parameterCount == 0 }
val entryField = findField(rowClass, "mEntry", "entry")
require(entryMethod != null || entryField != null) { "AOSP notification row entry is unavailable" }
entryForRow = memberAccessor(entryMethod, entryField)
val entryClass = entryMethod?.returnType ?: entryField!!.type
val sbnMethod = entryClass.methods.firstOrNull { it.name == "getSbn" && it.parameterCount == 0 }
val sbnField = findField(entryClass, "mSbn", "sbn", "mStatusBarNotification")
require(sbnMethod != null || sbnField != null) { "AOSP notification entry SBN is unavailable" }
val accessor = memberAccessor(sbnMethod, sbnField)
sbnForEntry = { accessor(it) as? StatusBarNotification }
val attachedChildren = rowClass.methods.firstOrNull {
it.name == "getAttachedChildren" && it.parameterCount == 0
}?.also { it.isAccessible = true }
attachedChildrenForRow = { row ->
(attachedChildren?.invoke(row) as? List<*>)?.filterIsInstance<View>().orEmpty()
}
reorderChildren = { row, ordered ->
@Suppress("UNCHECKED_CAST")
(attachedChildren?.invoke(row) as? MutableList<Any?>)?.let {
it.clear()
it.addAll(ordered)
}
}
val childrenContainer = requireField(rowClass, "mChildrenContainer")
val untruncatedCount = requireField(childrenContainer.type, "mUntruncatedChildCount")
setGroupCount = { row, count ->
childrenContainer.get(row)?.let { untruncatedCount.setInt(it, count) }
}
}
private fun installShelfFilter(classLoader: ClassLoader) {
val iconContainer = Class.forName(ICON_CONTAINER, false, classLoader)
val iconView = Class.forName(STATUS_BAR_ICON_VIEW, false, classLoader)
iconState = IconStateAccess(Class.forName(ICON_STATE, false, classLoader))
calculateShelfIcons = iconContainer.declaredMethods.singleOrNull {
it.name == "calculateIconXTranslations" && it.parameterCount == 0
}?.also { it.isAccessible = true } ?: error("AOSP shelf icon layout method not found")
shelfNotification = requireField(iconView, "mNotification")
shelfIconStates = requireField(iconContainer, "mIconStates")
framework.hook(calculateShelfIcons).intercept { chain ->
val container = chain.thisObject as? ViewGroup
?: return@intercept chain.proceed()
if (container.parent?.javaClass?.name != SHELF) return@intercept chain.proceed()
synchronized(shelves) { shelves += container }
val surface = surfaceFor(container) ?: return@intercept chain.proceed()
val children = List(container.childCount, container::getChildAt)
val states = shelfIconStates.get(container) as? Map<*, *>
?: return@intercept chain.proceed()
val blocked = children.filter { isBlockedShelfIcon(it, surface) }
blocked.forEach { states[it]?.let(iconState::hide) }
children.filterNot(blocked::contains).forEach(container::bringChildToFront)
val result = try {
chain.proceed()
} finally {
children.forEach(container::bringChildToFront)
}
blocked.forEach { states[it]?.let(iconState::hide) }
result
}
}
private fun track(stack: ViewGroup) {
synchronized(stacks) { stacks += stack }
}
private fun applyStack(stack: ViewGroup) {
val surface = surfaceFor(stack)
repeat(stack.childCount) { applyRow(stack.getChildAt(it), surface) }
stack.requestLayout()
}
private fun applyRow(stack: ViewGroup, row: View) = applyRow(row, surfaceFor(stack))
private fun applyRow(row: View, surface: NotificationSurface?) {
if (row.javaClass.name != ROW) return
if (surface == null) {
restoreTree(row)
return
}
restoreGroupOrder(row)
val children = attachedChildrenForRow(row)
val parentBlocked = isBlocked(row, surface)
val blockedChildren = children.map { it to isBlocked(it, surface) }
if (blockedChildren.any { it.second }) {
groupOrders.putIfAbsent(row, children)
val ordered = blockedChildren.partition { !it.second }.let { it.first + it.second }
reorderChildren(row, ordered.map { it.first })
setGroupCount(row, blockedChildren.count { !it.second })
}
blockedChildren.forEach { (child, blocked) ->
if (blocked) hideRow(child) else restoreRow(child)
}
if (parentBlocked && blockedChildren.all { it.second }) hideRow(row) else restoreRow(row)
}
private fun isBlocked(row: View, surface: NotificationSurface): Boolean {
val sbn = runCatching { entryForRow(row)?.let(sbnForEntry) }.getOrNull() ?: return false
return isBlocked(sbn, surface)
}
private fun isBlockedShelfIcon(icon: View, surface: NotificationSurface): Boolean {
if (icon.javaClass.name != STATUS_BAR_ICON_VIEW) return false
val sbn = runCatching { shelfNotification.get(icon) as? StatusBarNotification }.getOrNull()
?: return false
val row = rowForKey(icon, sbn.key)
return row?.let {
isBlocked(it, surface) && attachedChildrenForRow(it).none { child -> !isBlocked(child, surface) }
} ?: isBlocked(sbn, surface)
}
private fun rowForKey(view: View, key: String): View? {
val root = view.rootView
return synchronized(stacks) { stacks.toList() }
.asSequence()
.filter { it.rootView === root }
.flatMap { stack -> (0 until stack.childCount).asSequence().map(stack::getChildAt) }
.firstOrNull { row ->
row.javaClass.name == ROW &&
runCatching { entryForRow(row)?.let(sbnForEntry)?.key == key }.getOrDefault(false)
}
}
private fun isBlocked(sbn: StatusBarNotification, surface: NotificationSurface): Boolean =
ProcessVisibilityPolicyCache.isBlocked(
VisibilityNotificationExtractor.from(sbn),
surface,
).also { blocked ->
if (blocked && !blockedObserved) {
blockedObserved = true
Log.i(TAG, "Filtered an AOSP notification from $surface")
}
}
private fun surfaceFor(view: View): NotificationSurface? {
val root = view.rootView
val stack = synchronized(stacks) { stacks.firstOrNull { it.rootView === root } } ?: return null
return runCatching {
when {
onKeyguard.invoke(stack) as Boolean -> NotificationSurface.LOCKSCREEN_COLLAPSED
stackExpanded?.getBoolean(stack) == true -> NotificationSurface.DRAWER
else -> null
}
}.getOrNull()
}
private fun hideRow(row: View) {
if (row.visibility == View.GONE && row.alpha == 0f) return
hiddenRows.putIfAbsent(row, RowState(row.visibility, row.alpha))
row.visibility = View.GONE
row.alpha = 0f
}
private fun restoreRow(row: View) {
val state = hiddenRows.remove(row) ?: return
row.visibility = state.visibility
row.alpha = state.alpha
row.requestLayout()
(row.parent as? View)?.requestLayout()
}
private fun restoreTree(row: View) {
restoreGroupOrder(row)
if (row.javaClass.name == ROW) attachedChildrenForRow(row).forEach(::restoreRow)
restoreRow(row)
}
private fun restoreGroupOrder(row: View) {
val saved = groupOrders.remove(row) ?: return
val current = attachedChildrenForRow(row)
reorderChildren(row, saved.filter(current::contains) + current.filterNot(saved::contains))
setGroupCount(row, current.size)
}
private data class RowState(val visibility: Int, val alpha: Float)
private companion object {
const val TAG = "NotificationsMaster"
const val STACK = "com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout"
const val ROW = "com.android.systemui.statusbar.notification.row.ExpandableNotificationRow"
const val SHELF = "com.android.systemui.statusbar.NotificationShelf"
const val ICON_CONTAINER = "com.android.systemui.statusbar.phone.NotificationIconContainer"
const val ICON_STATE = "$ICON_CONTAINER\$IconState"
const val STATUS_BAR_ICON_VIEW = "com.android.systemui.statusbar.StatusBarIconView"
}
}
@@ -0,0 +1,308 @@
package se.ajpanton.notificationsmaster.module
import android.os.Build
import android.service.notification.StatusBarNotification
import android.util.Log
import io.github.libxposed.api.XposedInterface
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import se.ajpanton.notificationsmaster.visibility.VisibilityNotification
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import java.util.LinkedHashSet
import java.util.concurrent.ConcurrentHashMap
/** Filters the modern unlocked and AOD icon flows; the notification/shade model is untouched. */
internal class AospModernIconBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend {
private val metadata = ConcurrentHashMap<String, VisibilityNotification>()
private val activeKeys = ConcurrentHashMap<NotificationSurface, Set<String>>()
@Volatile private var policyChanges: Any? = null
private var policyRevision = 0L
private val lastFilterSignatures = ConcurrentHashMap<NotificationSurface, Long>()
@Volatile private var captureFailureLogged = false
override fun install(classLoader: ClassLoader): Boolean {
if (Build.VERSION.SDK_INT !in 35..36) return false
return runCatching {
val entryClass = Class.forName(NOTIFICATION_ENTRY, false, classLoader)
val modelClass = Class.forName(ACTIVE_NOTIFICATION_MODEL, false, classLoader)
val modelAccesses = buildList {
add(ModelAccess(
modelClass,
modelClass.requiredField("key", String::class.java),
modelClass.requiredField("packageName", String::class.java),
))
runCatching { Class.forName(ACTIVE_NOTIFICATION_ICON_MODEL, false, classLoader) }
.getOrNull()?.let { iconModel ->
add(ModelAccess(iconModel, iconModel.requiredField("notifKey", String::class.java), null))
}
}
installMetadataCapture(classLoader, entryClass, modelClass)
installFlowRefresh(classLoader)
installStatusBarFilter(classLoader, modelAccesses)
installAodFilter(classLoader, modelAccesses)
}.onSuccess {
Log.i(TAG, "Installed AOSP API ${Build.VERSION.SDK_INT} unlocked notification-icon backend")
}.onFailure {
Log.w(TAG, "AOSP API ${Build.VERSION.SDK_INT} unlocked icon shape is unsupported; leaving icons unchanged", it)
}.isSuccess
}
@Synchronized override fun onPolicyChanged() {
val signal = policyChanges ?: return
policyRevision += 1
runCatching {
signal.javaClass.methods.single { it.name == "setValue" && it.parameterTypes.size == 1 }
.invoke(signal, policyRevision)
}.onFailure { Log.w(TAG, "Could not refresh unlocked notification icons", it) }
}
private fun installMetadataCapture(classLoader: ClassLoader, entryClass: Class<*>, modelClass: Class<*>) {
val builder = Class.forName(ACTIVE_STORE_BUILDER, false, classLoader)
val method = builder.declaredMethods.singleOrNull {
it.name == "toModel" && it.returnType == modelClass && it.parameterTypes.contentEquals(arrayOf(entryClass))
} ?: error("Active notification conversion method not found")
val sbnField = entryClass.requiredField("mSbn", StatusBarNotification::class.java)
method.isAccessible = true
framework.hook(method).intercept { chain ->
val result = chain.proceed()
runCatching { sbnField.get(chain.args[0]) as StatusBarNotification }
.map(VisibilityNotificationExtractor::from)
.onSuccess { metadata[it.key] = it }
.onFailure {
if (!captureFailureLogged) {
captureFailureLogged = true
Log.w(TAG, "Notification metadata capture failed; text exceptions will fail open", it)
}
}
result
}
}
private fun installFlowRefresh(classLoader: ClassLoader) {
val interactor = Class.forName(STATUS_BAR_INTERACTOR, false, classLoader)
val flowField = interactor.requiredField("statusBarNotifs", null)
require(flowField.type.name == "kotlinx.coroutines.flow.Flow")
val flowClass = Class.forName("kotlinx.coroutines.flow.Flow", false, classLoader)
val functionClass = Class.forName("kotlin.jvm.functions.Function3", false, classLoader)
val signal = Class.forName("kotlinx.coroutines.flow.StateFlowKt", false, classLoader)
.declaredMethods.single { it.name == "MutableStateFlow" && it.parameterTypes.size == 1 }
.invoke(null, policyRevision)
val combineMethod = Class.forName("kotlinx.coroutines.flow.FlowKt", false, classLoader)
.methods.singleOrNull {
it.name == "combine" && it.parameterTypes.map(Class<*>::getName) == listOf(
flowClass.name,
flowClass.name,
functionClass.name,
)
}
val transformMethod = if (combineMethod == null) {
val function4 = Class.forName("kotlin.jvm.functions.Function4", false, classLoader)
Class.forName("kotlinx.coroutines.flow.FlowKt", false, classLoader).methods.singleOrNull {
it.name == "combineTransform" && it.parameterTypes.map(Class<*>::getName) == listOf(
flowClass.name,
flowClass.name,
function4.name,
)
}?.let { method ->
val collector = Class.forName("kotlinx.coroutines.flow.FlowCollector", false, classLoader)
val continuation = Class.forName("kotlin.coroutines.Continuation", false, classLoader)
val emit = collector.getMethod("emit", Any::class.java, continuation)
method to Proxy.newProxyInstance(classLoader, arrayOf(function4)) { proxy, called, args ->
when (called.name) {
"invoke" -> emit.invoke(args!![0], args[1], args[3])
"toString" -> "NotificationsMasterVisibilityTransform"
"hashCode" -> System.identityHashCode(proxy)
"equals" -> proxy === args?.get(0)
else -> error("Unexpected Function4 method ${called.name}")
}
}
}
} else {
null
}
val combineConstructor = if (combineMethod == null && transformMethod == null) {
Class.forName(TWO_FLOW_COMBINE, false, classLoader).declaredConstructors.singleOrNull {
it.parameterTypes.map(Class<*>::getName) == listOf(
flowClass.name,
flowClass.name,
functionClass.name,
)
}?.also { it.isAccessible = true }
} else {
null
}
require(combineMethod != null || transformMethod != null || combineConstructor != null) {
"Two-flow combine implementation not found"
}
val keepUpstreamValue = Proxy.newProxyInstance(classLoader, arrayOf(functionClass)) { proxy, method, args ->
when (method.name) {
"invoke" -> args?.get(0)
"toString" -> "NotificationsMasterVisibilityRefresh"
"hashCode" -> System.identityHashCode(proxy)
"equals" -> proxy === args?.get(0)
else -> error("Unexpected Function3 method ${method.name}")
}
}
fun refreshed(upstream: Any?) = combineMethod?.invoke(null, upstream, signal, keepUpstreamValue)
?: transformMethod?.let { (method, transform) -> method.invoke(null, upstream, signal, transform) }
?: combineConstructor!!.newInstance(upstream, signal, keepUpstreamValue)
policyChanges = signal
val constructor = interactor.declaredConstructors.singleOrNull {
it.parameterTypes.map { type -> type.name } == listOf(
"kotlin.coroutines.CoroutineContext",
"com.android.systemui.statusbar.notification.icon.domain.interactor.NotificationIconsInteractor",
"com.android.systemui.statusbar.data.repository.NotificationListenerSettingsRepository",
)
}
val (field, constructors) = if (constructor != null) {
flowField to listOf(constructor)
} else {
val viewModel = Class.forName(STATUS_BAR_VIEW_MODEL, false, classLoader)
viewModel.requiredField("icons", null).also {
require(flowClass.isAssignableFrom(it.type))
} to viewModel.declaredConstructors.toList()
}
require(constructors.isNotEmpty()) { "Status-bar flow owner has no constructors" }
constructors.forEach { target ->
target.isAccessible = true
framework.hook(target).intercept { chain ->
val result = chain.proceed()
runCatching {
field.set(chain.thisObject, refreshed(field.get(chain.thisObject)))
}.onFailure { Log.w(TAG, "Could not attach visibility refresh flow; leaving icons unchanged", it) }
result
}
}
runCatching {
val aod = Class.forName(AOD_VIEW_MODEL, false, classLoader)
val icons = aod.requiredField("icons", null).also { require(flowClass.isAssignableFrom(it.type)) }
require(aod.declaredConstructors.isNotEmpty()) { "AOD view model has no constructors" }
aod.declaredConstructors.forEach { constructor ->
constructor.isAccessible = true
framework.hook(constructor).intercept { chain ->
val result = chain.proceed()
icons.set(chain.thisObject, refreshed(icons.get(chain.thisObject)))
result
}
}
}.onFailure { Log.i(TAG, "AOD icon refresh flow is unavailable") }
}
private fun installStatusBarFilter(
classLoader: ClassLoader,
modelAccesses: List<ModelAccess>,
) {
val collector = statusBarMapCollectors().firstNotNullOfOrNull { name ->
runCatching { Class.forName(name, false, classLoader) }.getOrNull()
} ?: error("Status-bar icon collector class not found")
val emit = collector.declaredMethods.singleOrNull(::isEmitMethod)
?: error("Status-bar icon collector method not found")
emit.isAccessible = true
framework.hook(emit).intercept { chain ->
val original = chain.args[0]
val filtered = filter(original, modelAccesses, NotificationSurface.UNLOCKED_STATUSBAR)
if (filtered === original) chain.proceed() else chain.proceed(arrayOf(filtered, chain.args[1]))
}
}
private fun installAodFilter(classLoader: ClassLoader, modelAccesses: List<ModelAccess>) {
runCatching {
val collector = Class.forName(AOD_MAP_COLLECTOR, false, classLoader)
val emit = collector.declaredMethods.singleOrNull(::isEmitMethod)
?: error("AOD icon collector method not found")
emit.isAccessible = true
framework.hook(emit).intercept { chain ->
val original = chain.args[0]
val filtered = filter(original, modelAccesses, NotificationSurface.AOD)
if (filtered === original) chain.proceed() else chain.proceed(arrayOf(filtered, chain.args[1]))
}
}.onSuccess {
Log.i(TAG, "Installed AOSP AOD notification-icon backend")
}.onFailure {
Log.i(TAG, "AOSP AOD notification-icon boundary is unavailable")
}
}
private fun filter(
value: Any?,
modelAccesses: List<ModelAccess>,
surface: NotificationSurface,
): Any? {
val entries = value as? Set<*> ?: return value
if (entries.any { model -> model != null && modelAccesses.none { it.type.isInstance(model) } }) return value
fun access(model: Any) = modelAccesses.first { it.type.isInstance(model) }
val surfaceKeys = entries.mapNotNull { model ->
model?.let { access(it).key.get(it) as? String }
}.toSet()
activeKeys[surface] = surfaceKeys
metadata.keys.retainAll(activeKeys.values.flatten().toSet())
var changed = false
var missingMetadata = 0
val filtered = entries.filterTo(LinkedHashSet(entries.size)) { model ->
if (model == null) return@filterTo true
val fields = access(model)
val key = fields.key.get(model) as? String ?: return@filterTo true
val captured = metadata[key]
val packageName = fields.packageName?.get(model) as? String
?: captured?.packageName
?: key.split('|', limit = 3).getOrNull(1)
?: return@filterTo true
val hasExceptions = ProcessVisibilityPolicyCache.hasExceptions(packageName)
if (hasExceptions && captured == null) missingMetadata += 1
val notification = captured ?: VisibilityNotification(packageName, key)
val blocked = (!hasExceptions || captured != null) &&
ProcessVisibilityPolicyCache.isBlocked(notification, surface)
changed = changed || blocked
!blocked
}
val hidden = entries.size - filtered.size
val signature = ((entries.size * 31L + hidden) * 31L + missingMetadata)
if (lastFilterSignatures.put(surface, signature) != signature) {
Log.i(
TAG,
"${surface.name} notification icons: input=${entries.size}, hidden=$hidden, " +
"missingMetadata=$missingMetadata",
)
}
return if (changed) filtered else value
}
private data class ModelAccess(val type: Class<*>, val key: Field, val packageName: Field?)
private fun Class<*>.requiredField(name: String, type: Class<*>?): Field =
getDeclaredField(name).also {
require(type == null || it.type == type) { "Unexpected $name field type" }
it.isAccessible = true
}
private fun isEmitMethod(method: Method) = method.name == "emit" &&
method.parameterTypes.size == 2 && method.parameterTypes[1].name == "kotlin.coroutines.Continuation"
private fun statusBarMapCollectors() =
(if (Build.VERSION.SDK_INT == 35) listOf("2\$2", "1\$2") else listOf("1\$2"))
.map { STATUS_BAR_MAP_COLLECTOR_PREFIX + it }
private companion object {
const val TAG = "NotificationsMaster"
const val NOTIFICATION_ENTRY = "com.android.systemui.statusbar.notification.collection.NotificationEntry"
const val ACTIVE_NOTIFICATION_MODEL =
"com.android.systemui.statusbar.notification.shared.ActiveNotificationModel"
const val ACTIVE_NOTIFICATION_ICON_MODEL =
"com.android.systemui.statusbar.notification.icon.domain.interactor.ActiveNotificationIconModel"
const val ACTIVE_STORE_BUILDER =
"com.android.systemui.statusbar.notification.domain.interactor.ActiveNotificationsStoreBuilder"
const val STATUS_BAR_INTERACTOR =
"com.android.systemui.statusbar.notification.icon.domain.interactor.StatusBarNotificationIconsInteractor"
const val STATUS_BAR_VIEW_MODEL =
"com.android.systemui.statusbar.notification.icon.ui.viewmodel.NotificationIconContainerStatusBarViewModel"
const val AOD_VIEW_MODEL =
"com.android.systemui.statusbar.notification.icon.ui.viewmodel.NotificationIconContainerAlwaysOnDisplayViewModel"
const val AOD_MAP_COLLECTOR = "$AOD_VIEW_MODEL\$special\$\$inlined\$map\$1\$2"
const val STATUS_BAR_MAP_COLLECTOR_PREFIX =
"com.android.systemui.statusbar.notification.icon.ui.viewmodel." +
"NotificationIconContainerStatusBarViewModel\$special\$\$inlined\$map\$"
const val TWO_FLOW_COMBINE =
"kotlinx.coroutines.flow.FlowKt__ZipKt\$combine\$\$inlined\$unsafeFlow\$1"
}
}
@@ -0,0 +1,119 @@
package se.ajpanton.notificationsmaster.module
import android.app.Notification
import android.service.notification.StatusBarNotification
import android.util.Log
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
/** Prevents Android from collapsing this app's marked layout-test notifications. */
internal class DebugNotificationAutoGroupBridge(private val framework: XposedInterface) {
fun install(classLoader: ClassLoader) {
val groupHelper = findClass(GROUP_HELPER, classLoader) ?: return unavailable()
val recordClass = findClass(NOTIFICATION_RECORD, classLoader) ?: return unavailable()
val notificationManager = findClass(NOTIFICATION_MANAGER, classLoader) ?: return unavailable()
val blockGrouping = XposedInterface.Hooker { chain ->
if (!isDebugRecord(chain.args.firstOrNull())) return@Hooker chain.proceed()
if ((chain.executable as? Method)?.returnType == Boolean::class.javaPrimitiveType) false else null
}
GROUP_METHODS.forEach { hookMethods(groupHelper, it, blockGrouping) }
hookMethods(recordClass, "setOverrideGroupKey") { chain ->
if (isDebugRecord(chain.thisObject)) null else chain.proceed()
}
val dropSummary = XposedInterface.Hooker { chain ->
val info = notificationInfo(chain.args.toTypedArray())
if (info?.packageName == APP_PACKAGE && !info.isDebug && info.isGroupSummary) null
else chain.proceed()
}
hookMethods(notificationManager, "enqueueNotificationInternal", dropSummary)
hookMethods(notificationManager, "enqueueNotificationInternalLocked", dropSummary)
Log.i(TAG, "Installed debug-notification auto-group guard")
}
private fun hookMethods(type: Class<*>, name: String, hooker: XposedInterface.Hooker) {
type.declaredMethods.filter { it.name == name }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept(hooker)
}
}
private fun isDebugRecord(record: Any?): Boolean {
val sbn = invoke(record, "getSbn") as? StatusBarNotification
val packageName = sbn?.packageName ?: invoke(record, "getPackageName") as? String
val notification = sbn?.notification ?: invoke(record, "getNotification") as? Notification
return packageName == APP_PACKAGE && notification?.extras?.getBoolean(DEBUG_EXTRA, false) == true
}
private fun notificationInfo(args: Array<Any?>): NotificationInfo? {
var packageName: String? = null
var notification: Notification? = null
args.forEach { arg ->
when (arg) {
is StatusBarNotification -> {
packageName = arg.packageName
notification = arg.notification
}
is Notification -> notification = arg
is String -> if (packageName == null && arg == APP_PACKAGE) packageName = arg
else -> if (arg?.javaClass?.name?.contains("NotificationRecord") == true) {
val sbn = invoke(arg, "getSbn") as? StatusBarNotification
packageName = sbn?.packageName ?: invoke(arg, "getPackageName") as? String ?: packageName
notification = sbn?.notification ?: invoke(arg, "getNotification") as? Notification ?: notification
}
}
}
val value = notification ?: return null
return NotificationInfo(
packageName ?: return null,
value.extras?.getBoolean(DEBUG_EXTRA, false) == true,
value.flags and Notification.FLAG_GROUP_SUMMARY != 0,
)
}
private fun invoke(instance: Any?, name: String): Any? {
var type = instance?.javaClass
while (type != null) {
val method = runCatching { type.getDeclaredMethod(name) }.getOrNull()
if (method != null) return runCatching {
method.isAccessible = true
method.invoke(instance)
}.getOrNull()
type = type.superclass
}
return null
}
private fun findClass(name: String, classLoader: ClassLoader) =
runCatching { Class.forName(name, false, classLoader) }.getOrNull()
private fun unavailable() {
Log.w(TAG, "Debug-notification auto-group boundary is unavailable")
}
private data class NotificationInfo(
val packageName: String,
val isDebug: Boolean,
val isGroupSummary: Boolean,
)
private companion object {
const val TAG = "NotificationsMaster"
const val APP_PACKAGE = "se.ajpanton.notificationsmaster"
const val DEBUG_EXTRA = "se.ajpanton.notificationsmaster.DEBUG_NOTIFICATION"
const val GROUP_HELPER = "com.android.server.notification.GroupHelper"
const val NOTIFICATION_RECORD = "com.android.server.notification.NotificationRecord"
const val NOTIFICATION_MANAGER = "com.android.server.notification.NotificationManagerService"
val GROUP_METHODS = listOf(
"onNotificationPosted",
"onNotificationRemoved",
"maybeGroup",
"maybeUngroup",
"autoGroup",
"autogroup",
"maybeAutoGroup",
"shouldAutoGroup",
"isEligibleForAutoGroup",
)
}
}
@@ -0,0 +1,73 @@
package se.ajpanton.notificationsmaster.module
import android.os.Build
import android.service.notification.StatusBarNotification
import android.util.Log
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
/** Version-specific native-alert boundary with one shared policy decision. */
internal class NotificationAttentionBridge(
private val framework: XposedInterface,
private val observer: (SystemNotificationEvent) -> Unit,
) {
fun install(classLoader: ClassLoader) {
val method = attentionMethod(classLoader) ?: run {
Log.w(TAG, "Notification attention method shape was not found; leaving Android unchanged")
return
}
method.isAccessible = true
framework.hook(method).intercept { chain ->
val event = eventFrom(chain.args.firstOrNull())
if (event != null && SystemAlertPolicyCache.shouldSuppress(event.asAlertEvent())) {
observer(event)
Log.i(TAG, "Suppressed native notification effects for matched alert from " + event.packageName)
0
} else {
chain.proceed()
}
}
Log.i(TAG, "Installed API " + Build.VERSION.SDK_INT + " notification attention bridge")
}
private fun attentionMethod(classLoader: ClassLoader): Method? = when (Build.VERSION.SDK_INT) {
34 -> findMethod(classLoader, NOTIFICATION_MANAGER_SERVICE, ::isApi34Attention)
35, 36 -> findMethod(classLoader, ATTENTION_HELPER, ::isApi35PlusAttention)
else -> null
}
private fun findMethod(
classLoader: ClassLoader,
className: String,
matches: (Method) -> Boolean,
): Method? = runCatching {
Class.forName(className, false, classLoader).declaredMethods.singleOrNull(matches)
}.getOrNull()
private fun eventFrom(record: Any?): SystemNotificationEvent? = runCatching {
val method = record?.javaClass?.getDeclaredMethod("getSbn") ?: return null
method.isAccessible = true
val sbn = method.invoke(record) as? StatusBarNotification ?: return null
SystemNotificationEvent.fromStatusBarNotification(sbn)
}.getOrNull()
private companion object {
const val TAG = "NotificationsMaster"
const val ATTENTION_HELPER = "com.android.server.notification.NotificationAttentionHelper"
const val NOTIFICATION_MANAGER_SERVICE = "com.android.server.notification.NotificationManagerService"
const val NOTIFICATION_RECORD = "com.android.server.notification.NotificationRecord"
fun isApi34Attention(method: Method): Boolean =
method.name == "buzzBeepBlinkLocked" &&
method.returnType == Int::class.javaPrimitiveType &&
method.parameterTypes.size == 1 &&
method.parameterTypes[0].name == NOTIFICATION_RECORD
fun isApi35PlusAttention(method: Method): Boolean =
method.name == "buzzBeepBlinkLocked" &&
method.returnType == Int::class.javaPrimitiveType &&
method.parameterTypes.size == 2 &&
method.parameterTypes[0].name == NOTIFICATION_RECORD &&
method.parameterTypes[1].name.endsWith("NotificationAttentionHelper\$Signals")
}
}
@@ -0,0 +1,89 @@
package se.ajpanton.notificationsmaster.module
import android.os.Handler
import android.os.Looper
import android.util.Log
import io.github.libxposed.api.XposedModule
import io.github.libxposed.api.XposedModuleInterface
/**
* Optional libxposed entry point. It is inert unless the user enables this APK
* in a compatible framework and grants it a system-server scope.
*/
class NotificationsMasterModule : XposedModule() {
private var systemUiBackends: List<VisibilitySurfaceBackend>? = null
private var aodBackends: List<VisibilitySurfaceBackend>? = null
override fun onPackageReady(param: XposedModuleInterface.PackageReadyParam) {
if (param.packageName !in VisibilityPolicySync.TARGET_PACKAGES) return
if (param.packageName == SYSTEM_UI_PACKAGE) {
if (systemUiBackends != null) return
val unlocked = listOf(
OneUiUnlockedIconBackend(this),
Aosp34IconBackend(this),
AospModernIconBackend(this),
).firstOrNull { it.install(param.classLoader) }
systemUiBackends = listOfNotNull(unlocked) + listOf(
AospLockscreenBackend(this),
OneUiLockscreenBackend(this),
OneUiLockscreenCardsBackend(this),
SamsungAodPluginBackend(this),
OneUiMiscBackend(this),
).filter { it.install(param.classLoader) }
ProcessVisibilityPolicyCache.installWhenReady {
systemUiBackends.orEmpty().forEach(VisibilitySurfaceBackend::onPolicyChanged)
}
} else {
if (aodBackends != null) return
aodBackends = listOf(SamsungAodBackend(this)).filter { it.install(param.classLoader) }
ProcessVisibilityPolicyCache.installWhenReady {
aodBackends.orEmpty().forEach(VisibilitySurfaceBackend::onPolicyChanged)
}
}
}
override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) {
SystemAlertPolicyCache.installWhenReady()
Handler(Looper.getMainLooper()).post {
installBridge("debug notification auto-group") {
DebugNotificationAutoGroupBridge(this).install(param.classLoader)
}
installBridge("notification attention") {
NotificationAttentionBridge(this, ::diagnoseAttentionHelperEvent).install(param.classLoader)
}
installBridge("notification enqueue") {
SystemNotificationBridge(this, ::diagnoseHelperEvent).install(param.classLoader)
}
}
}
private fun installBridge(name: String, install: () -> Unit) {
runCatching(install).onFailure { error ->
Log.e(TAG, "$name bridge was not installed; leaving Android unchanged", error)
}
}
private fun diagnoseHelperEvent(event: SystemNotificationEvent) {
diagnoseHelperEvent("enqueue", event)
}
private fun diagnoseAttentionHelperEvent(event: SystemNotificationEvent) {
diagnoseHelperEvent("attention", event)
}
private fun diagnoseHelperEvent(phase: String, event: SystemNotificationEvent) {
if (event.packageName != TEST_HELPER_PACKAGE) return
val matches = SystemAlertPolicyCache.evaluate(event.asAlertEvent()).decisions.size
Log.i(
TAG,
"Observed helper $phase: title=" + event.title + " body=" + event.body?.replace("\n", " | ") +
" channel=" + event.channelId + " summary=" + event.isGroupSummary + " matches=" + matches,
)
}
private companion object {
const val TAG = "NotificationsMaster"
const val SYSTEM_UI_PACKAGE = "com.android.systemui"
const val TEST_HELPER_PACKAGE = "se.ajpanton.notificationsmaster.helper"
}
}
@@ -0,0 +1,479 @@
package se.ajpanton.notificationsmaster.module
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.graphics.drawable.GradientDrawable
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.FrameLayout
import android.widget.ImageView
import se.ajpanton.notificationsmaster.visibility.CardsGridLayout
import se.ajpanton.notificationsmaster.visibility.CardsGridSettings
import java.util.LinkedHashMap
import java.util.WeakHashMap
/** Renders a OneUI Cards-mode icon grid from notification icon snapshots. */
internal class OneUiCardsGridRenderer {
private val states = WeakHashMap<ViewGroup, State>()
private val nativeSurfaces = WeakHashMap<View, NativeSurface>()
fun updateSources(container: ViewGroup, sources: List<Source>) {
val unique = LinkedHashMap<String, Source>()
sources.forEach { unique.putIfAbsent(it.key, it) }
state(container).sources = unique.values.toList()
}
fun refresh(container: ViewGroup, active: Boolean, visibleCardKeys: Set<String>, config: Config) {
val state = state(container)
if (!active) {
state.config = config
suspend(container, false)
return
}
state.active = true
state.visibleCardKeys = visibleCardKeys
state.config = config
val native = if (config.hideShelf) findNativeSurface(container) else container
if (native !== container.rootView) hideNative(state, native)
scheduleRender(container, state)
}
fun refreshAll(
active: (ViewGroup) -> Boolean,
visibleCardKeys: (ViewGroup) -> Set<String>,
config: Config,
suppressNativeWhenInactive: (ViewGroup) -> Boolean = { false },
) {
states.keys.toList().forEach { container ->
if (active(container)) {
refresh(container, true, visibleCardKeys(container), config)
} else {
state(container).config = config
suspend(container, suppressNativeWhenInactive(container))
}
}
}
fun deactivate(container: ViewGroup) {
states[container]?.let { suspend(container, false) }
}
fun deactivateAll() {
states.keys.toList().forEach(::deactivate)
}
fun suspend(container: ViewGroup, suppressNative: Boolean, nativeSurface: View? = null) {
val state = state(container)
state.active = false
cancelRender(container, state)
state.host?.visibility = View.GONE
if (suppressNative) {
val native = nativeSurface
?: if (state.config.hideShelf) findNativeSurface(container) else container
if (native !== container.rootView) hideNative(state, native)
} else {
restoreNative(state)
}
}
fun release(container: ViewGroup) {
val state = states.remove(container) ?: return
restoreNative(state)
state.attachListener?.let(container::removeOnAttachStateChangeListener)
removePreDrawListener(state)
cancelRender(container, state)
state.layoutListener?.let { state.root?.removeOnLayoutChangeListener(it) }
state.host?.let { host ->
clearHost(host)
(host.parent as? ViewGroup)?.removeView(host)
}
}
private fun scheduleRender(container: ViewGroup, state: State) {
if (state.renderTask != null) return
val task = Runnable {
state.renderTask = null
if (states[container] === state) render(container, state)
}
state.renderTask = task
if (!container.post(task)) state.renderTask = null
}
private fun cancelRender(container: ViewGroup, state: State) {
state.renderTask?.let(container::removeCallbacks)
state.renderTask = null
}
private fun render(container: ViewGroup, state: State) {
if (!state.active) return
if (!container.isAttachedToWindow) return failOpen(state)
val root = container.rootView as? ViewGroup ?: return failOpen(state)
if (state.config.settings.maxRows == 0) {
state.host?.let {
clearHost(it)
it.visibility = View.GONE
}
return
}
val remaining = state.sources.filterNot { it.key in state.visibleCardKeys }
if (remaining.isEmpty()) {
failOpen(state)
return
}
val host = attachHost(root, container, state) ?: return failOpen(state)
val iconSize = iconSize(root, container, state.config.settings)
val settings = state.config.settings
val horizontalPadding = dp(root, 8)
val widthCapacity = if (iconSize > 0) {
(root.width - horizontalPadding * 2).coerceAtLeast(0) / iconSize
} else 0
val rows = CardsGridLayout.arrange(remaining.size, settings, widthCapacity)
val location = relativeLocation(container, root) ?: return failOpen(state)
if (root.width <= 0 || rows.visibleSlots == 0) return failOpen(state)
val iconCount = rows.visibleSlots - if (rows.showsOverflow) 1 else 0
val icons = remaining.take(iconCount).map { notificationIcon(root, it, iconSize) }
if (icons.any { it == null }) {
icons.filterNotNull().forEach(::recycleIcon)
return failOpen(state)
}
clearHost(host)
host.visibility = View.VISIBLE
host.alpha = 1f
host.translationX = 0f
host.translationY = 0f
state.anchorLocation = location
val rowGap = dp(root, 8)
val nativeSurface = if (state.config.hideShelf) findNativeSurface(container) else container
if (nativeSurface === root) return failOpen(state)
var sourceIndex = 0
var slotIndex = 0
rows.rowSizes.forEachIndexed { row, count ->
val rowWidth = count * iconSize
val left = (location.first + container.width / 2 - rowWidth / 2).let {
if (settings.limitToScreenWidth) {
it.coerceIn(horizontalPadding, (root.width - rowWidth - horizontalPadding).coerceAtLeast(horizontalPadding))
} else it
}
val top = location.second + row * (iconSize + rowGap)
if (state.config.showPills) {
host.addView(pill(root, nativeSurface, left, top, rowWidth, iconSize))
}
repeat(count) { column ->
val overflow = rows.showsOverflow && slotIndex == rows.visibleSlots - 1
val icon = if (overflow) overflowDot(root, iconSize) else icons[sourceIndex++]!!
host.addView(icon, frame(iconSize, iconSize, left + column * iconSize, top))
slotIndex++
}
}
hideNative(state, nativeSurface)
syncSourceMovement(container, state)
val diagnosticSignature = listOf(remaining.size, state.visibleCardKeys.size, rows.visibleSlots,
settings.maxRows, settings.maxIconsPerRow, iconSize).hashCode()
if (diagnosticSignature != state.lastDiagnosticSignature) {
state.lastDiagnosticSignature = diagnosticSignature
Log.i(TAG, "Rendered OneUI Cards grid: cards=${state.visibleCardKeys.size} " +
"remaining=${remaining.size} slots=${rows.visibleSlots} overflow=${rows.showsOverflow}")
}
}
private fun attachHost(root: ViewGroup, container: ViewGroup, state: State): FrameLayout? {
state.host?.takeIf { it.parent === root }?.let { return it }
state.layoutListener?.let { listener -> state.root?.removeOnLayoutChangeListener(listener) }
state.host?.let { (it.parent as? ViewGroup)?.removeView(it) }
return runCatching {
FrameLayout(root.context).apply {
tag = HOST_TAG
clipChildren = false
clipToPadding = false
isClickable = false
isFocusable = false
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
root.addView(this, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
))
state.host = this
state.root = root
val listener = View.OnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
refresh(container, state.active, state.visibleCardKeys, state.config)
}
}
state.layoutListener = listener
root.addOnLayoutChangeListener(listener)
}
}.getOrNull()
}
private fun notificationIcon(root: View, source: Source, size: Int): ImageView? {
var drawable = findDrawable(source.view)
?: runCatching {
val context = source.packageName?.let { root.context.createPackageContext(it, 0) }
?: root.context
source.smallIcon?.loadDrawable(context)
}.getOrNull()
?: return null
source.tintColor?.let { color -> drawable = drawable.mutate().apply { setTint(color) } }
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val bounds = Rect(drawable.bounds)
try {
drawable.bounds = aspectFit(drawable, bounds, size)
drawable.draw(Canvas(bitmap))
} finally {
drawable.bounds = bounds
}
return ImageView(root.context).apply {
setImageBitmap(bitmap)
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
}
private fun overflowDot(root: View, size: Int) = View(root.context).apply {
background = object : GradientDrawable() {
override fun draw(canvas: Canvas) {
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE }
val radius = size * 0.125f
canvas.drawCircle(size / 2f, size / 2f, radius, paint)
}
}
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
private fun pill(root: View, native: View, left: Int, top: Int, width: Int, height: Int): View {
val horizontal = dp(root, 8)
val vertical = dp(root, 3)
return View(root.context).apply {
background = native.background?.constantState?.newDrawable(root.resources)?.mutate()
?: GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = (height + vertical * 2) / 2f
setColor(DEFAULT_PILL_COLOR)
}
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
layoutParams = frame(
width + horizontal * 2,
height + vertical * 2,
left - horizontal,
top - vertical,
)
}
}
private fun iconSize(root: View, source: View, settings: CardsGridSettings): Int {
val id = root.resources.getIdentifier("status_bar_height", "dimen", "android")
val statusBarHeight = if (id != 0) root.resources.getDimensionPixelSize(id) else 0
val fallback = minOf(source.width, source.height).coerceAtLeast(1)
return if (statusBarHeight > 0) {
(statusBarHeight * settings.iconHeightPercent / 100f).toInt().coerceAtLeast(1)
} else fallback
}
private fun relativeLocation(view: View, root: View): Pair<Int, Int>? = runCatching {
val viewLocation = IntArray(2)
val rootLocation = IntArray(2)
view.getLocationOnScreen(viewLocation)
root.getLocationOnScreen(rootLocation)
viewLocation[0] - rootLocation[0] to viewLocation[1] - rootLocation[1]
}.getOrNull()
private fun findNativeSurface(container: ViewGroup): View {
var current = container.parent as? View
while (current != null && current !== container.rootView) {
if (current is ViewGroup && current.javaClass.name.contains("NotificationShelf")) {
return findShelfBackground(current) ?: container
}
current = current.parent as? View
}
return container
}
private fun findShelfBackground(root: ViewGroup): View? {
val pending = ArrayDeque<View>().apply { add(root) }
while (pending.isNotEmpty()) {
val view = pending.removeFirst()
if (view.javaClass.name.contains("NotificationShelfBackground")) return view
if (view is ViewGroup) repeat(view.childCount) { pending += view.getChildAt(it) }
}
return null
}
private fun clearHost(host: ViewGroup) {
repeat(host.childCount) { index -> recycleIcon(host.getChildAt(index)) }
host.removeAllViews()
}
private fun recycleIcon(view: View) {
((view as? ImageView)?.drawable as? BitmapDrawable)?.bitmap
?.takeUnless(Bitmap::isRecycled)?.recycle()
}
private fun failOpen(state: State) {
restoreNative(state)
state.host?.let {
it.visibility = View.GONE
it.translationX = 0f
it.translationY = 0f
}
state.anchorLocation = null
}
private fun hideNative(state: State, native: View) {
if (state.nativeSurface !== native) {
restoreNative(state)
state.nativeSurface = native
nativeSurfaces.getOrPut(native) { NativeSurface(native.alpha) }.owners += state
}
state.nativeSuppressed = true
native.alpha = 0f
}
private fun restoreNative(state: State) {
state.nativeSuppressed = false
state.nativeSurface?.let { native ->
nativeSurfaces[native]?.let { surface ->
surface.owners -= state
if (surface.owners.isEmpty()) {
native.alpha = surface.alpha
nativeSurfaces.remove(native)
}
}
}
state.nativeSurface = null
}
private fun installPreDrawListener(container: ViewGroup, state: State) {
removePreDrawListener(state)
val observer = container.viewTreeObserver.takeIf(ViewTreeObserver::isAlive) ?: return
val listener = ViewTreeObserver.OnPreDrawListener {
if (state.nativeSuppressed) state.nativeSurface?.let { native ->
if (native.alpha != 0f) native.alpha = 0f
}
if (state.active && state.config.followSourceMovement) syncSourceMovement(container, state)
true
}
observer.addOnPreDrawListener(listener)
state.preDrawObserver = observer
state.preDrawListener = listener
}
private fun removePreDrawListener(state: State) {
val listener = state.preDrawListener ?: return
state.preDrawObserver?.takeIf(ViewTreeObserver::isAlive)?.removeOnPreDrawListener(listener)
state.preDrawObserver = null
state.preDrawListener = null
}
private fun findDrawable(view: View?): Drawable? = when (view) {
is ImageView -> view.drawable
is ViewGroup -> (0 until view.childCount).firstNotNullOfOrNull { findDrawable(view.getChildAt(it)) }
else -> null
}
private fun aspectFit(drawable: Drawable, fallback: Rect, size: Int): Rect {
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: fallback.width().coerceAtLeast(1)
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: fallback.height().coerceAtLeast(1)
val scale = minOf(size.toFloat() / width, size.toFloat() / height)
val targetWidth = (width * scale).toInt().coerceAtLeast(1)
val targetHeight = (height * scale).toInt().coerceAtLeast(1)
val left = (size - targetWidth) / 2
val top = (size - targetHeight) / 2
return Rect(left, top, left + targetWidth, top + targetHeight)
}
private fun frame(width: Int, height: Int, left: Int, top: Int) =
FrameLayout.LayoutParams(width, height).apply {
leftMargin = left
topMargin = top
}
private fun dp(view: View, value: Int) = (value * view.resources.displayMetrics.density).toInt()
private fun syncSourceMovement(container: ViewGroup, state: State) {
val root = state.root ?: return
val host = state.host ?: return
val anchor = state.anchorLocation ?: return
container.getLocationOnScreen(state.sourceLocation)
root.getLocationOnScreen(state.rootLocation)
val x = (state.sourceLocation[0] - state.rootLocation[0] - anchor.first).toFloat()
val y = (state.sourceLocation[1] - state.rootLocation[1] - anchor.second).toFloat()
val alpha = effectiveAncestorAlpha(container, root)
if (host.translationX != x) host.translationX = x
if (host.translationY != y) host.translationY = y
if (host.alpha != alpha) host.alpha = alpha
}
private fun effectiveAncestorAlpha(source: View, root: View): Float {
if (source.visibility != View.VISIBLE) return 0f
var alpha = 1f
var current = source.parent as? View
while (current != null && current !== root) {
if (current.visibility != View.VISIBLE) return 0f
alpha *= current.alpha
current = current.parent as? View
}
return alpha
}
private fun state(container: ViewGroup) = states.getOrPut(container) {
State().also { state ->
state.attachListener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(view: View) = installPreDrawListener(container, state)
override fun onViewDetachedFromWindow(view: View) = release(container)
}.also(container::addOnAttachStateChangeListener)
if (container.isAttachedToWindow) installPreDrawListener(container, state)
}
}
data class Source(
val key: String,
val view: View?,
val smallIcon: android.graphics.drawable.Icon?,
val packageName: String? = null,
val tintColor: Int? = null,
)
data class Config(
val settings: CardsGridSettings,
val showPills: Boolean,
val hideShelf: Boolean,
val followSourceMovement: Boolean = false,
)
private class State {
var sources = emptyList<Source>()
var visibleCardKeys = emptySet<String>()
var host: FrameLayout? = null
var root: ViewGroup? = null
var layoutListener: View.OnLayoutChangeListener? = null
var attachListener: View.OnAttachStateChangeListener? = null
var active = false
var nativeSurface: View? = null
var nativeSuppressed = false
var preDrawObserver: ViewTreeObserver? = null
var preDrawListener: ViewTreeObserver.OnPreDrawListener? = null
var renderTask: Runnable? = null
var anchorLocation: Pair<Int, Int>? = null
val sourceLocation = IntArray(2)
val rootLocation = IntArray(2)
var lastDiagnosticSignature = 0
var config = Config(CardsGridSettings.LOCKSCREEN_DEFAULT, showPills = true, hideShelf = true)
}
private class NativeSurface(val alpha: Float) {
val owners = mutableSetOf<State>()
}
private companion object {
const val HOST_TAG = "notifications-master:cards-grid"
const val DEFAULT_PILL_COLOR = 0xAA202124.toInt()
const val TAG = "NotificationsMaster"
}
}
@@ -0,0 +1,119 @@
package se.ajpanton.notificationsmaster.module
import android.os.Handler
import android.os.Looper
import android.service.notification.StatusBarNotification
import android.util.Log
import io.github.libxposed.api.XposedInterface
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import java.lang.reflect.Method
import java.util.ArrayList
import java.util.Collections
import java.util.WeakHashMap
/** Filters OneUI's collapsed lockscreen projection without changing notification rows. */
internal class OneUiLockscreenBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend {
private val coordinators = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
private val replayingLockscreen = ThreadLocal<Boolean>()
private lateinit var refresh: Method
@Volatile private var blockedObserved = false
override fun install(classLoader: ClassLoader): Boolean = runCatching {
val coordinator = Class.forName(COORDINATOR, false, classLoader)
val iconsController = Class.forName(ICONS_CONTROLLER, false, classLoader)
val info = Class.forName(NOTIFICATION_INFO, false, classLoader)
val sbn = info.getDeclaredField("mSbn").also {
require(it.type == StatusBarNotification::class.java)
it.isAccessible = true
}
val attach = coordinator.declaredMethods.singleOrNull {
it.name == "attach" && it.parameterTypes.size == 1 && it.returnType == Void.TYPE
} ?: error("OneUI lockscreen coordinator attach method not found")
refresh = coordinator.declaredMethods.singleOrNull {
it.name == "onLockScreenNotiStateChanged" &&
it.parameterTypes.isEmpty() && it.returnType == Void.TYPE
} ?: error("OneUI lockscreen refresh method not found")
val updateIcons = iconsController.declaredMethods.singleOrNull {
it.name == "onNotificationInfoUpdated" &&
it.parameterTypes.contentEquals(arrayOf(ArrayList::class.java)) && it.returnType == Void.TYPE
} ?: error("OneUI lockscreen icon update method not found")
attach.isAccessible = true
refresh.isAccessible = true
updateIcons.isAccessible = true
framework.hook(attach).intercept { chain ->
synchronized(coordinators) { coordinators += chain.thisObject }
chain.proceed()
}
framework.hook(updateIcons).intercept { chain ->
if (replayingLockscreen.get() == true) return@intercept chain.proceed()
val original = chain.args[0] as? ArrayList<*> ?: return@intercept chain.proceed()
if (original.any { it != null && !info.isInstance(it) }) return@intercept chain.proceed()
val aod = filter(original, sbn, NotificationSurface.AOD)
val lockscreen = filter(original, sbn, NotificationSurface.LOCKSCREEN_COLLAPSED)
// OneUI builds AOD icons and lockscreen state in this same callback. Let
// AOD consume its list, then replay only the lockscreen half.
val result = chain.proceed(arrayOf(aod))
if (lockscreen != aod) {
replayingLockscreen.set(true)
OneUiSurfaceDispatch.suppressAodIconUpdate.set(true)
try {
updateIcons.invoke(chain.thisObject, lockscreen)
} catch (error: ReflectiveOperationException) {
throw error.cause ?: error
} finally {
OneUiSurfaceDispatch.suppressAodIconUpdate.remove()
replayingLockscreen.remove()
}
}
result
}
}.onSuccess {
Log.i(TAG, "Installed OneUI collapsed-lockscreen notification backend")
}.onFailure {
Log.d(TAG, "OneUI collapsed-lockscreen boundary is unavailable")
}.isSuccess
override fun onPolicyChanged() {
blockedObserved = false
Handler(Looper.getMainLooper()).post {
val current = synchronized(coordinators) { coordinators.toList() }
current.forEach { coordinator ->
runCatching { refresh.invoke(coordinator) }
.onFailure { Log.w(TAG, "Could not refresh OneUI collapsed-lockscreen notifications", it) }
}
}
}
private fun filter(
original: ArrayList<*>,
sbn: java.lang.reflect.Field,
surface: NotificationSurface,
): ArrayList<*> {
val filtered = original.filterTo(ArrayList(original.size)) { item ->
val notification = item?.let {
runCatching {
VisibilityNotificationExtractor.from(sbn.get(it) as StatusBarNotification)
}.getOrNull()
}
notification == null || !ProcessVisibilityPolicyCache.isBlocked(notification, surface)
}
if (filtered.size < original.size && !blockedObserved) {
blockedObserved = true
Log.i(TAG, "Filtered a OneUI ${surface.name.lowercase()} notification")
}
return filtered
}
private companion object {
const val TAG = "NotificationsMaster"
const val COORDINATOR =
"com.android.systemui.statusbar.notification.collection.coordinator.LockScreenNotiIconCoordinator"
const val ICONS_CONTROLLER =
"com.android.systemui.statusbar.iconsOnly.LockscreenNotificationIconsOnlyController"
const val NOTIFICATION_INFO = "com.android.systemui.statusbar.LockscreenNotificationInfo"
}
}
internal object OneUiSurfaceDispatch {
val suppressAodIconUpdate = ThreadLocal<Boolean>()
}
@@ -0,0 +1,376 @@
package se.ajpanton.notificationsmaster.module
import android.provider.Settings
import android.service.notification.StatusBarNotification
import android.util.Log
import android.view.View
import android.view.ViewGroup
import io.github.libxposed.api.XposedInterface
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import java.lang.reflect.Field
import java.util.Collections
import java.util.WeakHashMap
import java.util.function.Function
/** Filters OneUI notification rows in Cards-mode lockscreen and the notification drawer. */
internal class OneUiLockscreenCardsBackend(
private val framework: XposedInterface,
) : VisibilitySurfaceBackend {
private val stacks = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
private val hiddenRows = WeakHashMap<View, RowState>()
private val gridRenderer = OneUiCardsGridRenderer()
private lateinit var entryForRow: (View) -> Any?
private lateinit var sbnForEntry: (Any) -> StatusBarNotification?
private var stackExpanded: Field? = null
private lateinit var shelfNotification: Field
private lateinit var shelfIconStates: Field
private lateinit var iconState: IconStateAccess
@Volatile private var statusBarStateKnown = false
@Volatile private var statusBarState = SHADE
@Volatile private var dozing = false
@Volatile private var dozeAmount = 0f
@Volatile private var blockedObserved = false
@Volatile private var nativeShelfSuppressedObserved = false
override fun install(classLoader: ClassLoader): Boolean = runCatching {
Class.forName(ONE_UI_LOCKSCREEN_COORDINATOR, false, classLoader)
val stackClass = Class.forName(STACK, false, classLoader)
val rowClass = Class.forName(ROW, false, classLoader)
val stateController = Class.forName(STATE_CONTROLLER, false, classLoader)
stackExpanded = findField(stackClass, "mIsExpanded")
prepareNotificationAccess(rowClass)
installShelfFilter(classLoader)
installGridSourceHook(classLoader)
stackClass.declaredMethods.filter { it.name == "onViewAdded" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
val result = chain.proceed()
(chain.thisObject as? ViewGroup)?.let { stack ->
synchronized(stacks) { stacks += stack }
(chain.args.firstOrNull() as? View)?.let(::applyRow)
}
result
}
}
stackClass.declaredMethods.filter { it.name == "onViewRemoved" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
(chain.args.firstOrNull() as? View)?.let(::restoreRow)
chain.proceed()
}
}
stackClass.declaredMethods.filter { it.name == "onMeasure" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
(chain.thisObject as? ViewGroup)?.let { stack ->
synchronized(stacks) { stacks += stack }
applyStack(stack)
}
chain.proceed()
}
}
stateController.declaredConstructors.forEach { constructor ->
constructor.isAccessible = true
framework.hook(constructor).intercept { chain ->
val result = chain.proceed()
updateState(chain.thisObject)
result
}
}
stateController.declaredMethods.filter {
it.name == "setState" || it.name == "setDozeAmountInternal"
}.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
val leavingLockscreen = method.name == "setState" && chain.args.firstOrNull() != KEYGUARD
if (method.name == "setDozeAmountInternal") {
dozeAmount = ((chain.args.firstOrNull() as? Number)?.toFloat() ?: 0f)
.coerceIn(0f, 1f)
}
val enteringDoze = dozeAmount > 0f
if (enteringDoze) {
gridRenderer.refreshAll({ false }, { emptySet() }, gridConfig(), ::isCardsMode)
} else if (leavingLockscreen) {
gridRenderer.deactivateAll()
}
val result = chain.proceed()
updateState(chain.thisObject)
result
}
}
}.onSuccess {
Log.i(TAG, "Installed OneUI lockscreen-cards/drawer notification backend")
}.onFailure {
Log.d(TAG, "OneUI lockscreen-cards boundary is unavailable", it)
}.isSuccess
override fun onPolicyChanged() {
blockedObserved = false
applyTrackedStacks()
}
private fun prepareNotificationAccess(rowClass: Class<*>) {
val entryMethod = rowClass.methods.firstOrNull { it.name == "getEntry" && it.parameterCount == 0 }
val entryField = findField(rowClass, "mEntry", "entry")
require(entryMethod != null || entryField != null) { "OneUI notification row entry is unavailable" }
entryForRow = memberAccessor(entryMethod, entryField)
val entryClass = entryMethod?.returnType ?: entryField!!.type
val sbnMethod = entryClass.methods.firstOrNull { it.name == "getSbn" && it.parameterCount == 0 }
val sbnField = findField(entryClass, "mSbn", "sbn", "mStatusBarNotification")
require(sbnMethod != null || sbnField != null) { "OneUI notification entry SBN is unavailable" }
val accessor = memberAccessor(sbnMethod, sbnField)
sbnForEntry = { accessor(it) as? StatusBarNotification }
}
private fun installShelfFilter(classLoader: ClassLoader) {
val shelfIcons = Class.forName(SHELF_ICONS, false, classLoader)
val iconView = Class.forName(STATUS_BAR_ICON_VIEW, false, classLoader)
shelfNotification = iconView.getDeclaredField("mNotification").also {
require(it.type == StatusBarNotification::class.java)
it.isAccessible = true
}
shelfIconStates = findField(shelfIcons, "mIconStates")
?: error("OneUI shelf icon states are unavailable")
iconState = IconStateAccess(Class.forName(ICON_STATE, false, classLoader))
val calculate = shelfIcons.declaredMethods.singleOrNull {
it.name == "calculateIconXTranslations" && it.parameterCount == 0
} ?: error("OneUI shelf icon layout method not found")
calculate.isAccessible = true
framework.hook(calculate).intercept { chain ->
val container = chain.thisObject as? ViewGroup ?: return@intercept chain.proceed()
if (isDozeActive() && isCardsMode(container)) {
gridRenderer.suspend(container, true)
}
val surface = rowSurface(container) ?: return@intercept chain.proceed()
val lockscreenCards = surface == NotificationSurface.LOCKSCREEN_COLLAPSED
val children = List(container.childCount, container::getChildAt)
val states = shelfIconStates.get(container) as? Map<*, *>
?: return@intercept chain.proceed()
val blocked = children.filter { isBlockedShelfIcon(it, surface) }
blocked.forEach { icon -> states[icon]?.let(iconState::hide) }
children.filterNot(blocked::contains).forEach(container::bringChildToFront)
val result = try {
chain.proceed()
} finally {
children.forEach(container::bringChildToFront)
}
// OneUI applies these states after this calculation returns. In Cards mode,
// the renderer owns the whole supplemental shelf; in the drawer, only hide
// icons whose notification rows are hidden.
(if (lockscreenCards) children else blocked).forEach { icon ->
states[icon]?.let(iconState::hide)
}
if (lockscreenCards && !nativeShelfSuppressedObserved) {
nativeShelfSuppressedObserved = true
Log.i(TAG, "Suppressed the native OneUI Cards shelf icon states")
}
result
}
}
private fun installGridSourceHook(classLoader: ClassLoader) {
val controller = Class.forName(ICON_AREA_CONTROLLER, false, classLoader)
val shelfClass = Class.forName(SHELF_ICONS, false, classLoader)
controller.declaredMethods.filter { it.name == "updateIconsForLayout" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
val functionIndex = chain.args.indexOfFirst { it is Function<*, *> }
val container = chain.args.firstOrNull { shelfClass.isInstance(it) } as? ViewGroup
if (functionIndex < 0 || container == null) {
return@intercept chain.proceed()
}
@Suppress("UNCHECKED_CAST")
val original = chain.args[functionIndex] as Function<Any?, Any?>
val sources = ArrayList<OneUiCardsGridRenderer.Source>()
val wrapped = Function<Any?, Any?> { entry ->
val result = original.apply(entry)
val sbn = entry?.let { runCatching { sbnForEntry(it) }.getOrNull() }
if (result is View && sbn != null && !isBlocked(sbn)) {
sources += OneUiCardsGridRenderer.Source(
sbn.key,
result,
sbn.notification.smallIcon,
sbn.packageName,
)
}
result
}
val arguments = chain.args.toTypedArray()
arguments[functionIndex] = wrapped
val result = chain.proceed(arguments)
gridRenderer.updateSources(container, sources)
val active = isLockscreenCards(container)
if (active) {
gridRenderer.refresh(container, true, visibleCardKeys(container), gridConfig())
} else {
gridRenderer.suspend(container, isDozeActive() && isCardsMode(container))
}
result
}
}
}
private fun updateState(controller: Any) {
(findField(controller.javaClass, "mState")?.get(controller) as? Int)?.let {
statusBarState = it
statusBarStateKnown = true
}
(findField(controller.javaClass, "mIsDozing")?.get(controller) as? Boolean)?.let { dozing = it }
(findField(controller.javaClass, "mDozeAmount")?.get(controller) as? Number)?.let {
dozeAmount = it.toFloat().coerceIn(0f, 1f)
}
applyTrackedStacks()
}
private fun applyTrackedStacks() {
synchronized(stacks) { stacks.toList() }.forEach(::applyStack)
gridRenderer.refreshAll(
::isLockscreenCards,
::visibleCardKeys,
gridConfig(),
) { isDozeActive() && isCardsMode(it) }
}
private fun applyStack(stack: ViewGroup) {
val surface = rowSurface(stack)
repeat(stack.childCount) { index ->
val row = stack.getChildAt(index)
if (row.javaClass.name == ROW) {
if (surface != null && isBlocked(row, surface)) hideRow(row) else restoreRow(row)
}
}
}
private fun applyRow(row: View) {
val stack = row.parent as? ViewGroup ?: return
val surface = rowSurface(stack)
if (surface != null && isBlocked(row, surface)) hideRow(row) else restoreRow(row)
}
private fun rowSurface(view: View): NotificationSurface? = when {
shouldFilterLockscreenCards(view) -> NotificationSurface.LOCKSCREEN_COLLAPSED
statusBarStateKnown && statusBarState != KEYGUARD && !isDozeActive() && isStackExpanded(view) ->
NotificationSurface.DRAWER
else -> null
}
private fun isStackExpanded(view: View): Boolean {
val root = view.rootView
val stack = synchronized(stacks) { stacks.firstOrNull { it.rootView === root } } ?: return false
return runCatching { stackExpanded?.getBoolean(stack) == true }.getOrDefault(false)
}
private fun isLockscreenCards(view: View) =
shouldFilterLockscreenCards(view) && !isDozeActive()
private fun shouldFilterLockscreenCards(view: View) =
statusBarStateKnown && statusBarState == KEYGUARD && !isSteadyAod() && isCardsMode(view)
private fun isDozeActive() = dozing || dozeAmount > 0f
private fun isSteadyAod() = dozing && dozeAmount >= 1f
private fun isCardsMode(view: View) = runCatching {
Settings.System.getInt(view.context.contentResolver, LOCKSCREEN_STYLE) == CARDS
}.getOrDefault(false)
private fun isBlocked(row: View, surface: NotificationSurface): Boolean {
val sbn = runCatching { entryForRow(row)?.let(sbnForEntry) }.getOrNull() ?: return false
return isBlocked(sbn, surface)
}
private fun isBlockedShelfIcon(icon: View, surface: NotificationSurface): Boolean {
if (icon.javaClass.name != STATUS_BAR_ICON_VIEW) return false
val sbn = runCatching { shelfNotification.get(icon) as? StatusBarNotification }.getOrNull()
?: return false
return isBlocked(sbn, surface)
}
private fun isBlocked(sbn: StatusBarNotification) =
isBlocked(sbn, NotificationSurface.LOCKSCREEN_COLLAPSED)
private fun isBlocked(sbn: StatusBarNotification, surface: NotificationSurface): Boolean {
return ProcessVisibilityPolicyCache.isBlocked(
VisibilityNotificationExtractor.from(sbn),
surface,
).also { blocked ->
if (blocked && !blockedObserved) {
blockedObserved = true
Log.i(TAG, "Filtered a OneUI notification from $surface")
}
}
}
private fun hideRow(row: View) {
if (row.visibility == View.GONE && row.alpha == 0f) return
hiddenRows.putIfAbsent(row, RowState(row.visibility, row.alpha))
row.visibility = View.GONE
row.alpha = 0f
}
private fun restoreRow(row: View) {
val state = hiddenRows.remove(row) ?: return
row.visibility = state.visibility
row.alpha = state.alpha
setBooleanField(row, "mWillBeGone", false)
setBooleanField(row, "mInShelf", false)
setBooleanField(row, "mTransformingInShelf", false)
findField(row.javaClass, "mViewState")?.get(row)?.let { viewState ->
setBooleanField(viewState, "hidden", false)
setBooleanField(viewState, "gone", false)
}
row.requestLayout()
(row.parent as? View)?.requestLayout()
}
private fun setBooleanField(target: Any, name: String, value: Boolean) {
findField(target.javaClass, name)?.let { field ->
if (field.get(target) is Boolean) field.setBoolean(target, value)
}
}
private fun visibleCardKeys(container: ViewGroup): Set<String> {
val root = container.rootView
return synchronized(stacks) { stacks.toList() }
.asSequence()
.filter { it.rootView === root }
.flatMap { stack -> (0 until stack.childCount).asSequence().map(stack::getChildAt) }
.filter { row ->
row.javaClass.name == ROW && row.visibility == View.VISIBLE && row.alpha > 0.05f &&
!booleanField(row, "mInShelf") && !booleanField(row, "mTransformingInShelf")
}
.mapNotNull { row -> runCatching { entryForRow(row)?.let(sbnForEntry)?.key }.getOrNull() }
.toSet()
}
private fun booleanField(target: Any, name: String) =
runCatching { findField(target.javaClass, name)?.getBoolean(target) ?: false }.getOrDefault(false)
private fun gridConfig() = OneUiCardsGridRenderer.Config(
ProcessVisibilityPolicyCache.lockscreenCards,
showPills = true,
hideShelf = true,
)
private data class RowState(val visibility: Int, val alpha: Float)
private companion object {
const val TAG = "NotificationsMaster"
const val SHADE = 0
const val KEYGUARD = 1
const val CARDS = 0
const val LOCKSCREEN_STYLE = "lockscreen_minimizing_notification"
const val STACK = "com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout"
const val ROW = "com.android.systemui.statusbar.notification.row.ExpandableNotificationRow"
const val STATE_CONTROLLER = "com.android.systemui.statusbar.StatusBarStateControllerImpl"
const val SHELF_ICONS = "com.android.systemui.statusbar.phone.SecShelfNotificationIconContainer"
const val STATUS_BAR_ICON_VIEW = "com.android.systemui.statusbar.StatusBarIconView"
const val ICON_STATE = "com.android.systemui.statusbar.phone.NotificationIconContainer\$IconState"
const val ICON_AREA_CONTROLLER =
"com.android.systemui.statusbar.phone.LegacyNotificationIconAreaControllerImpl"
const val ONE_UI_LOCKSCREEN_COORDINATOR =
"com.android.systemui.statusbar.notification.collection.coordinator.LockScreenNotiIconCoordinator"
}
}
@@ -0,0 +1,446 @@
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.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() {
lockscreenBattery.refresh()
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<out Enum<*>>, "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 controller: Any? = null
private var batteryEvent: Any? = null
private var restingEvent: Any? = null
private lateinit var addTimeout: Method
private lateinit var addResting: Method
private lateinit var remove: Method
private var failed = false
fun install(classLoader: ClassLoader): Boolean = runCatching {
val eventClass = requireNotNull(classLoader.findClass("com.android.systemui.statusbar.IndicationEventType"))
batteryEvent = requireNotNull(eventClass.staticField("BATTERY"))
restingEvent = requireNotNull(eventClass.staticField("BATTERY_RESTING"))
val controllerClass = requireNotNull(
classLoader.findClass("com.android.systemui.statusbar.KeyguardSecIndicationController"),
)
addTimeout = controllerClass.getDeclaredMethod(
"addIndicationTimeout",
eventClass,
CharSequence::class.java,
ColorStateList::class.java,
Boolean::class.javaPrimitiveType,
).also { it.isAccessible = true }
addResting = controllerClass.getDeclaredMethod(
"addIndication",
eventClass,
CharSequence::class.java,
).also { it.isAccessible = true }
remove = controllerClass.getDeclaredMethod("removeIndication", eventClass).also { it.isAccessible = true }
val method = controllerClass.getDeclaredMethod("addInitialIndication").also { it.isAccessible = true }
framework.intercept(method) { chain ->
val result = chain.proceed()
controller = chain.thisObject
update(chain.thisObject)
result
}
classLoader.findClass("com.android.systemui.statusbar.KeyguardIndicationController\$4")
?.getDeclaredMethod("onDozingChanged", Boolean::class.javaPrimitiveType)
?.also { it.isAccessible = true }
?.let { dozingChanged ->
framework.intercept(dozingChanged) { chain ->
val result = chain.proceed()
val target = chain.thisObject.field("this\$0")
if (chain.args.firstOrNull() == false && controllerClass.isInstance(target)) {
controller = target
update(target)
}
result
}
}
}.onFailure { error ->
Log.w(TAG, "OneUI lockscreen battery boundary is unavailable", error)
}.isSuccess
fun refresh() {
val target = controller ?: return
(target.field("mIndicationArea") as? View)?.post { update(target) } ?: update(target)
}
private fun update(target: Any?) = runCatching {
if (target.field("mDozing") == true) return@runCatching
val context = target.field("mContext") as? Context ?: return@runCatching
val state = currentBatteryState(context) ?: return@runCatching
if (state.plugged || state.charged) return@runCatching
val battery = batteryEvent ?: return@runCatching
val resting = restingEvent ?: return@runCatching
if (!ProcessVisibilityPolicyCache.systemUiMisc.showLockscreenBatteryPercentWhenUnplugged) {
remove.invoke(target, battery)
remove.invoke(target, resting)
return@runCatching
}
val text = state.formattedPercent()
val color = target.field("mInitialTextColorState") as? ColorStateList
addTimeout.invoke(target, battery, text, color, false)
addResting.invoke(target, resting, text)
}.onFailure(::logFailureOnce)
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<Runnable>()
private val savedText = WeakHashMap<TextView, Pair<CharSequence, Int>>()
private val savedVisibility = WeakHashMap<View, Int>()
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 = app.registerReceiver(null, filter, Context.RECEIVER_EXPORTED) ?: 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 ClassLoader.findClass(name: String) = runCatching { Class.forName(name, false, this) }.getOrNull()
private fun Class<*>.methodsNamed(name: String): List<Method> = declaredMethods.filter { it.name == name }.onEach {
it.isAccessible = true
}
private fun Class<*>.staticField(name: String): Any? = findField(this, name)?.get(null)
private fun Any?.field(name: String): Any? = this?.let { findField(it.javaClass, name)?.get(it) }
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"
@@ -0,0 +1,167 @@
package se.ajpanton.notificationsmaster.module
import android.os.Handler
import android.os.Looper
import android.service.notification.StatusBarNotification
import android.util.Log
import android.view.View
import android.view.ViewGroup
import io.github.libxposed.api.XposedInterface
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import java.lang.reflect.Method
import java.util.Collections
import java.util.WeakHashMap
import java.util.function.Function
/** Filters only OneUI's native unlocked status-bar container. */
internal class OneUiUnlockedIconBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend {
private val controllers = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
private val iconContainers = Collections.newSetFromMap(WeakHashMap<View, Boolean>())
private lateinit var updateStatusBarIcons: Method
private lateinit var maxStaticIcons: java.lang.reflect.Field
@Volatile private var blockedObserved = false
override fun install(classLoader: ClassLoader): Boolean = runCatching {
val controller = Class.forName(CONTROLLER, false, classLoader)
val container = Class.forName(ICON_CONTAINER, false, classLoader)
val entry = Class.forName(NOTIFICATION_ENTRY, false, classLoader)
val notificationIcons = controller.getDeclaredField("mNotificationIcons").also {
require(it.type == container)
it.isAccessible = true
}
maxStaticIcons = container.getDeclaredField("mMaxStaticIcons").also {
require(it.type == Int::class.javaPrimitiveType)
it.isAccessible = true
}
val sbn = entry.getDeclaredField("mSbn").also {
require(it.type == StatusBarNotification::class.java)
it.isAccessible = true
}
val update = controller.declaredMethods.singleOrNull {
it.name == "updateIconsForLayout" &&
it.returnType == Void.TYPE &&
it.parameterTypes.contentEquals(
arrayOf(
Function::class.java,
container,
Boolean::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType,
),
)
} ?: error("OneUI icon update method not found")
updateStatusBarIcons = controller.declaredMethods.singleOrNull {
it.name == "updateStatusBarIcons" && it.parameterTypes.isEmpty() && it.returnType == Void.TYPE
} ?: error("OneUI status-bar icon refresh method not found")
update.isAccessible = true
updateStatusBarIcons.isAccessible = true
framework.hook(updateStatusBarIcons).intercept { chain ->
synchronized(controllers) { controllers += chain.thisObject }
(notificationIcons.get(chain.thisObject) as? View)?.let(::manage)
chain.proceed()
}
framework.hook(update).intercept { chain ->
val icons = notificationIcons.get(chain.thisObject)
if (chain.args[1] !== icons) return@intercept chain.proceed()
(icons as? View)?.let(::manage)
@Suppress("UNCHECKED_CAST")
val original = chain.args[0] as? Function<Any, Any?> ?: return@intercept chain.proceed()
val wrapped = Function<Any, Any?> { item ->
val notification = runCatching {
VisibilityNotificationExtractor.from(sbn.get(item) as StatusBarNotification)
}.getOrNull()
if (notification == null || !ProcessVisibilityPolicyCache.isBlocked(
notification,
NotificationSurface.UNLOCKED_STATUSBAR,
)) {
original.apply(item)
} else {
if (!blockedObserved) {
blockedObserved = true
Log.i(TAG, "Filtered a OneUI unlocked notification icon")
}
null
}
}
val args = chain.args.toTypedArray()
args[0] = wrapped
chain.proceed(args)
}
container.declaredMethods.filter { it.name == "calculateIconXTranslations" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
(chain.thisObject as? View)?.takeIf(::isManaged)?.let(::applyLimit)
chain.proceed()
}
}
container.declaredMethods.filter { it.name == "shouldForceOverflow" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
val view = chain.thisObject as? View
if (view == null || !isManaged(view) || chain.args.size < 4) {
return@intercept chain.proceed()
}
val limit = unlockedLimit()
applyLimit(view)
val index = chain.args[0] as? Int ?: return@intercept chain.proceed()
val speedBump = chain.args[1] as? Int ?: -1
val appear = chain.args[2] as? Float ?: 0f
(speedBump != -1 && index >= speedBump && appear > 0f) || index >= limit
}
}
}.onSuccess {
Log.i(TAG, "Installed OneUI unlocked notification-icon backend")
}.onFailure {
Log.d(TAG, "OneUI unlocked icon boundary is unavailable")
}.isSuccess
override fun onPolicyChanged() {
blockedObserved = false
Handler(Looper.getMainLooper()).post {
val current = synchronized(controllers) { controllers.toList() }
current.forEach { controller ->
runCatching { updateStatusBarIcons.invoke(controller) }
.onFailure { Log.w(TAG, "Could not refresh OneUI unlocked icons", it) }
}
}
}
private fun manage(view: View) {
synchronized(iconContainers) { iconContainers += view }
applyLimit(view)
widenParent(view)
}
private fun isManaged(view: View) = synchronized(iconContainers) { view in iconContainers }
private fun applyLimit(view: View) {
maxStaticIcons.setInt(view, unlockedLimit())
}
private fun unlockedLimit() = ProcessVisibilityPolicyCache.unlockedIconLimit ?: UNLIMITED_ICONS
private fun widenParent(view: View) {
var parent = view.parent as? View
repeat(3) {
val current = parent ?: return
if (current.javaClass.name.contains("AlphaOptimized")) {
current.layoutParams?.takeIf {
it.width == ViewGroup.LayoutParams.WRAP_CONTENT || it.width == 0
}?.let {
it.width = ViewGroup.LayoutParams.MATCH_PARENT
current.layoutParams = it
}
}
parent = current.parent as? View
}
}
private companion object {
const val TAG = "NotificationsMaster"
const val CONTROLLER = "com.android.systemui.statusbar.phone.LegacyNotificationIconAreaControllerImpl"
const val ICON_CONTAINER = "com.android.systemui.statusbar.phone.NotificationIconContainer"
const val NOTIFICATION_ENTRY = "com.android.systemui.statusbar.notification.collection.NotificationEntry"
const val UNLIMITED_ICONS = 1000
}
}
@@ -0,0 +1,103 @@
package se.ajpanton.notificationsmaster.module
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Handler
import android.os.Looper
import android.util.Log
import se.ajpanton.notificationsmaster.visibility.CompiledVisibilityPolicy
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import se.ajpanton.notificationsmaster.visibility.VisibilityNotification
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyJson
/** Process-local policy used by SystemUI/AOD hooks without entering the app process. */
internal object ProcessVisibilityPolicyCache {
@Volatile
private var policy = CompiledVisibilityPolicy.ALLOW_ALL
private var installed = false
private var attempts = 0
private var onChanged: () -> Unit = {}
fun installWhenReady(onChanged: () -> Unit = {}) {
this.onChanged = onChanged
Handler(Looper.getMainLooper()).post(::tryInstall)
}
fun isBlocked(notification: VisibilityNotification, surface: NotificationSurface) =
policy.isBlocked(notification, surface)
fun hasExceptions(packageName: String) = policy.hasExceptions(packageName)
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
val context = currentApplicationContext()
if (context == null || context.packageName !in VisibilityPolicySync.TARGET_PACKAGES) {
retryOrGiveUp()
return
}
runCatching {
context.registerReceiver(
PolicyReceiver(),
IntentFilter(VisibilityPolicySync.UPDATE_ACTION),
VisibilityPolicySync.PERMISSION,
null,
Context.RECEIVER_EXPORTED,
)
context.sendBroadcast(
Intent(VisibilityPolicySync.REQUEST_ACTION).setComponent(
ComponentName(VisibilityPolicySync.APP_PACKAGE, VisibilityPolicySync.REQUEST_RECEIVER),
).putExtra(
VisibilityPolicySync.EXTRA_REQUESTER,
PendingIntent.getBroadcast(
context,
0,
Intent(REQUESTER_IDENTITY_ACTION).setPackage(context.packageName),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
),
)
}.onSuccess {
installed = true
Log.i(TAG, "Installed visibility-policy cache in ${context.packageName}; requested replay")
}.onFailure {
retryOrGiveUp()
}
}
private fun retryOrGiveUp() {
if (++attempts < MAX_ATTEMPTS) {
Handler(Looper.getMainLooper()).postDelayed(::tryInstall, RETRY_DELAY_MILLIS)
} else {
Log.w(TAG, "Visibility-policy cache unavailable; allowing all notifications")
}
}
private class PolicyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val payload = intent.getByteArrayExtra(VisibilityPolicySync.EXTRA_POLICY)
val next = if (payload == null || payload.size > VisibilityPolicySync.MAX_POLICY_BYTES) null else {
runCatching { CompiledVisibilityPolicy(VisibilityPolicyJson.decode(payload)) }
.onFailure { Log.w(TAG, "Rejected malformed visibility policy; allowing all", it) }
.getOrNull()
}
if (next != null && next.generation < policy.generation) return
policy = next ?: CompiledVisibilityPolicy.ALLOW_ALL
Log.i(TAG, "Visibility policy cache generation ${policy.generation}")
onChanged()
}
}
private const val TAG = "NotificationsMaster"
private const val REQUESTER_IDENTITY_ACTION = "se.ajpanton.notificationsmaster.VISIBILITY_REQUESTER_IDENTITY"
private const val MAX_ATTEMPTS = 10
private const val RETRY_DELAY_MILLIS = 1_000L
}
@@ -0,0 +1,359 @@
package se.ajpanton.notificationsmaster.module
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.provider.Settings
import android.service.notification.StatusBarNotification
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import io.github.libxposed.api.XposedInterface
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import java.lang.reflect.Method
import java.util.Collections
import java.util.WeakHashMap
/** Filters the notification lists handed to Samsung's AOD renderer. */
internal class SamsungAodBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend {
private val managers = Collections.synchronizedMap(WeakHashMap<Any, State>())
private val cardsContainers = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
private val cardsDozing = Collections.synchronizedMap(WeakHashMap<ViewGroup, Boolean>())
private val cardsTransitions = Collections.synchronizedMap(WeakHashMap<ViewGroup, CardsTransition>())
private val cardsRenderer = OneUiCardsGridRenderer()
private val mainHandler = Handler(Looper.getMainLooper())
private val refreshing = ThreadLocal<Boolean>()
private lateinit var updateActive: Method
private lateinit var updateVisible: Method
@Volatile private var blockedObserved = false
@Volatile private var notificationIconViews = emptyList<ImageView>()
private var cardsRefreshTask: Runnable? = null
override fun install(classLoader: ClassLoader): Boolean = runCatching {
val manager = Class.forName(MANAGER, false, classLoader)
updateActive = manager.exactMethod("updateActiveNotifications", List::class.java)
updateVisible = manager.exactMethod(
"updateVisibleNotifications",
List::class.java,
List::class.java,
Int::class.javaPrimitiveType!!,
)
val updateIcons = manager.exactMethod("updateNotification", ArrayList::class.java)
installCardsGrid(classLoader)
framework.hook(updateActive).intercept { chain ->
val raw = notificationList(chain.args[0]) ?: return@intercept chain.proceed()
if (refreshing.get() != true) state(chain.thisObject).active = raw
proceedFiltered(chain, 0, raw)
}
framework.hook(updateVisible).intercept { chain ->
val first = notificationList(chain.args[0]) ?: return@intercept chain.proceed()
val second = notificationList(chain.args[1]) ?: return@intercept chain.proceed()
if (refreshing.get() != true) {
state(chain.thisObject).visible = VisibleState(first, second, chain.args[2] as Int)
}
val filteredFirst = filter(first)
val filteredSecond = filter(second)
val result = if (filteredFirst === first && filteredSecond === second) chain.proceed() else {
chain.proceed(arrayOf(filteredFirst, filteredSecond, chain.args[2]))
}
refreshCardsGrid()
result
}
framework.hook(updateIcons).intercept { chain ->
if (OneUiSurfaceDispatch.suppressAodIconUpdate.get() == true) return@intercept null
val icons = imageViewList(chain.args[0]) ?: return@intercept chain.proceed()
val result = chain.proceed()
notificationIconViews = icons
refreshCardsGrid()
result
}
}.onSuccess {
Log.i(TAG, "Installed Samsung AOD notification backend")
}.onFailure { error ->
Log.w(TAG, "Samsung AOD notification boundary is unavailable", error)
}.isSuccess
override fun onPolicyChanged() {
blockedObserved = false
mainHandler.post {
val current = synchronized(managers) { managers.entries.map { it.key to it.value.copy() } }
refreshing.set(true)
try {
current.forEach { (manager, state) ->
state.active?.let { updateActive.invoke(manager, filter(it)) }
state.visible?.let {
updateVisible.invoke(manager, filter(it.first), filter(it.second), it.type)
}
}
} catch (error: ReflectiveOperationException) {
Log.w(TAG, "Could not refresh Samsung AOD notifications", error)
} finally {
refreshing.remove()
}
refreshCardsGrid()
}
}
private fun installCardsGrid(classLoader: ClassLoader) {
runCatching {
val container = Class.forName(AOD_ICON_CONTAINER, false, classLoader)
container.declaredConstructors.forEach { constructor ->
constructor.isAccessible = true
framework.hook(constructor).intercept { chain ->
val result = chain.proceed()
(chain.thisObject as? ViewGroup)?.let(::trackCardsContainer)
result
}
}
container.declaredMethods.filter {
it.name == "setDozing" && it.parameterCount == 1 &&
it.parameterTypes[0] == Boolean::class.javaPrimitiveType
}.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
val container = chain.thisObject as? ViewGroup
val dozing = chain.args.firstOrNull() == true
val origin = container?.let(::screenLocation)
container?.let {
trackCardsContainer(it)
cardsDozing[it] = false
cardsRenderer.suspend(it, isCardsMode(it), it)
}
val result = chain.proceed()
container?.let { view ->
cardsDozing[view] = dozing
if (dozing) beginDozeTransition(view, origin ?: screenLocation(view)) else {
cardsTransitions.remove(view)
cardsRenderer.suspend(view, isCardsMode(view), view)
}
}
result
}
}
container.declaredMethods.filter {
(it.name == "setIconColor" || it.name == "setIconSize") && it.parameterCount == 1
}.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
val result = chain.proceed()
(chain.thisObject as? ViewGroup)?.let(::trackCardsContainer)
refreshCardsGrid()
result
}
}
}.onFailure { error ->
Log.d(TAG, "Samsung AOD Cards grid boundary is unavailable", error)
}
}
private fun trackCardsContainer(container: ViewGroup) {
synchronized(cardsContainers) {
if (!cardsContainers.add(container)) return
}
container.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(view: View) {
if (cardsDozing[container] == true) beginAttachedTransition(container) else refreshCardsGrid()
}
override fun onViewDetachedFromWindow(view: View) {
cardsDozing[container] = false
cardsTransitions.remove(container)
cardsRenderer.release(container)
}
})
container.addOnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
refreshCardsGrid()
}
}
refreshCardsGrid()
}
private fun beginDozeTransition(container: ViewGroup, origin: Pair<Int, Int>) {
val current = screenLocation(container)
beginTransition(container, origin, current, current != origin)
}
private fun beginAttachedTransition(container: ViewGroup) {
val current = screenLocation(container)
beginTransition(container, current, current, true)
}
private fun beginTransition(
container: ViewGroup,
origin: Pair<Int, Int>,
current: Pair<Int, Int>,
moved: Boolean,
) {
val now = SystemClock.uptimeMillis()
cardsTransitions[container] = CardsTransition(
origin,
current,
moved,
stableSince = now,
deadline = now + TRANSITION_FALLBACK_DELAY,
)
cardsRenderer.suspend(container, isCardsMode(container), container)
refreshCardsGrid(POSITION_POLL_DELAY)
}
private fun refreshCardsGrid(delay: Long = 0L) {
cardsRefreshTask?.let(mainHandler::removeCallbacks)
cardsRefreshTask = Runnable {
cardsRefreshTask = null
renderCardsGrid()
}.also { mainHandler.postDelayed(it, delay) }
}
private fun renderCardsGrid() {
val ready = ArrayList<ViewGroup>()
var pollAgain = false
synchronized(cardsContainers) { cardsContainers.toList() }.forEach { container ->
if (!container.isAttachedToWindow) return@forEach
if (!isCardsMode(container)) {
cardsTransitions.remove(container)
cardsRenderer.deactivate(container)
return@forEach
}
if (cardsDozing[container] != true) {
cardsTransitions.remove(container)
cardsRenderer.suspend(container, true, container)
return@forEach
}
val location = screenLocation(container)
val transition = cardsTransitions[container]
if (transition == null) {
ready += container
return@forEach
}
cardsRenderer.suspend(container, true, container)
val now = SystemClock.uptimeMillis()
if (location != transition.lastLocation) {
transition.moved = transition.moved || location != transition.origin
transition.lastLocation = location
transition.stableSince = now
}
if (transition.moved && now - transition.stableSince >= POSITION_STABLE_DELAY ||
now >= transition.deadline
) {
cardsTransitions.remove(container)
ready += container
} else {
pollAgain = true
}
}
if (pollAgain) refreshCardsGrid(POSITION_POLL_DELAY)
if (ready.isEmpty()) return
val config = OneUiCardsGridRenderer.Config(
ProcessVisibilityPolicyCache.aodCards,
showPills = false,
hideShelf = false,
followSourceMovement = true,
)
ready.forEach { container ->
val sources = notificationIconViews.map { icon ->
OneUiCardsGridRenderer.Source(
"aod@${System.identityHashCode(icon)}",
icon,
smallIcon = null,
)
}
cardsRenderer.updateSources(container, sources)
cardsRenderer.refresh(
container,
active = true,
emptySet(),
config,
)
}
}
private fun screenLocation(view: View): Pair<Int, Int> {
val location = IntArray(2)
view.getLocationOnScreen(location)
return location[0] to location[1]
}
private fun isCardsMode(view: View) = runCatching {
Settings.System.getInt(view.context.contentResolver, NOTIFICATION_STYLE) == CARDS
}.getOrDefault(false)
private fun proceedFiltered(
chain: XposedInterface.Chain,
argument: Int,
raw: List<StatusBarNotification>,
filtered: List<StatusBarNotification> = filter(raw),
): Any? {
if (filtered === raw) return chain.proceed()
val args = chain.args.toTypedArray()
args[argument] = filtered
return chain.proceed(args)
}
private fun filter(raw: List<StatusBarNotification>): List<StatusBarNotification> {
val filtered = raw.filterNot { sbn ->
runCatching {
ProcessVisibilityPolicyCache.isBlocked(
VisibilityNotificationExtractor.from(sbn),
NotificationSurface.AOD,
)
}.getOrDefault(false)
}
if (filtered.size == raw.size) return raw
if (!blockedObserved) {
blockedObserved = true
Log.i(TAG, "Filtered a Samsung AOD notification")
}
return filtered
}
private fun notificationList(value: Any?): List<StatusBarNotification>? {
val list = value as? List<*> ?: return null
if (list.any { it != null && it !is StatusBarNotification }) return null
@Suppress("UNCHECKED_CAST")
return ArrayList(list as List<StatusBarNotification>)
}
private fun imageViewList(value: Any?): List<ImageView>? {
val list = value as? ArrayList<*> ?: return null
if (list.any { it !is ImageView }) return null
@Suppress("UNCHECKED_CAST")
return ArrayList(list as List<ImageView>)
}
private fun state(manager: Any) = synchronized(managers) { managers.getOrPut(manager, ::State) }
private fun Class<*>.exactMethod(name: String, vararg parameters: Class<*>) =
getDeclaredMethod(name, *parameters).also { it.isAccessible = true }
private data class State(
var active: List<StatusBarNotification>? = null,
var visible: VisibleState? = null,
)
private data class VisibleState(
val first: List<StatusBarNotification>,
val second: List<StatusBarNotification>,
val type: Int,
)
private data class CardsTransition(
val origin: Pair<Int, Int>,
var lastLocation: Pair<Int, Int>,
var moved: Boolean,
var stableSince: Long,
val deadline: Long,
)
private companion object {
const val TAG = "NotificationsMaster"
const val CARDS = 0
const val POSITION_POLL_DELAY = 80L
const val POSITION_STABLE_DELAY = 180L
const val TRANSITION_FALLBACK_DELAY = 2_500L
const val NOTIFICATION_STYLE = "lockscreen_minimizing_notification"
const val MANAGER = "com.samsung.android.uniform.plugins.notification.AODNotificationManager"
const val AOD_ICON_CONTAINER =
"com.samsung.android.uniform.widget.notification.NotificationIconsOnlyContainer"
}
}
@@ -0,0 +1,50 @@
package se.ajpanton.notificationsmaster.module
import android.util.Log
import io.github.libxposed.api.XposedInterface
import java.util.Collections
import java.util.IdentityHashMap
/** Installs the Samsung AOD backend after SystemUI supplies its plugin class loader. */
internal class SamsungAodPluginBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend {
private val classLoaders = Collections.newSetFromMap(IdentityHashMap<ClassLoader, Boolean>())
private val backends = mutableListOf<VisibilitySurfaceBackend>()
override fun install(classLoader: ClassLoader): Boolean = runCatching {
val manager = Class.forName(PLUGIN_MANAGER, false, classLoader)
val methods = manager.declaredMethods.filter { it.name == SET_PLUGIN }
require(methods.isNotEmpty()) { "$SET_PLUGIN is unavailable" }
methods.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
val result = chain.proceed()
chain.args.firstOrNull()?.javaClass?.classLoader?.let(::installPlugin)
result
}
}
}.onSuccess {
Log.i(TAG, "Installed Samsung AOD plugin-loader backend")
}.onFailure { error ->
Log.d(TAG, "Samsung AOD plugin-loader boundary is unavailable", error)
}.isSuccess
override fun onPolicyChanged() {
synchronized(backends) { backends.toList() }.forEach(VisibilitySurfaceBackend::onPolicyChanged)
}
private fun installPlugin(classLoader: ClassLoader) {
synchronized(classLoaders) {
if (!classLoaders.add(classLoader)) return
}
SamsungAodBackend(framework).takeIf { it.install(classLoader) }?.let { backend ->
synchronized(backends) { backends += backend }
backend.onPolicyChanged()
}
}
private companion object {
const val TAG = "NotificationsMaster"
const val PLUGIN_MANAGER = "com.android.systemui.doze.PluginAODManager"
const val SET_PLUGIN = "setAODPlugin"
}
}
@@ -0,0 +1,103 @@
package se.ajpanton.notificationsmaster.module
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Handler
import android.os.Looper
import android.util.Log
import se.ajpanton.notificationsmaster.alerts.AlertConfiguration
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationJson
import se.ajpanton.notificationsmaster.alerts.AlertEvaluation
import se.ajpanton.notificationsmaster.alerts.AlertPolicyEvaluator
import se.ajpanton.notificationsmaster.alerts.AlertEvent
import se.ajpanton.notificationsmaster.alerts.suppressesOriginal
/**
* A system-server-local, read-only copy of the app's alert rules.
*
* Notification hooks only read this volatile snapshot; they never synchronously
* enter the app process. Missing, rejected, or malformed policy always means
* no suppression.
*/
internal object SystemAlertPolicyCache {
@Volatile
private var configuration = AlertConfiguration(enabled = false)
@Volatile
private var playbackReady = false
private var installed = false
private var attempts = 0
fun installWhenReady() {
Handler(Looper.getMainLooper()).post(::tryInstall)
}
private fun tryInstall() {
if (installed) return
val context = systemContext()
if (context == null) {
retryOrGiveUp()
return
}
runCatching {
context.registerReceiver(
PolicyReceiver(),
IntentFilter().apply {
addAction(AlertPolicySync.ACTION)
addAction(AlertPolicySync.PLAYBACK_STATE_ACTION)
},
AlertPolicySync.PERMISSION,
null,
Context.RECEIVER_EXPORTED,
)
}.onSuccess {
installed = true
Log.i(TAG, "Installed fail-closed alert policy cache for " + context.packageName)
}.onFailure {
retryOrGiveUp()
}
}
fun evaluate(event: AlertEvent): AlertEvaluation =
AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate = false)
fun shouldSuppress(event: AlertEvent): Boolean =
playbackReady && evaluate(event).suppressesOriginal()
private fun systemContext(): Context? = runCatching {
val activityThread = Class.forName("android.app.ActivityThread")
val current = activityThread.getMethod("currentActivityThread").invoke(null) ?: return null
activityThread.getMethod("getSystemContext").invoke(current) as? Context
}.getOrNull()
private fun retryOrGiveUp() {
attempts += 1
if (attempts < MAX_ATTEMPTS) {
Handler(Looper.getMainLooper()).postDelayed(::tryInstall, RETRY_DELAY_MILLIS)
} else {
Log.w(TAG, "System policy cache is unavailable; leaving native alerts unchanged")
}
}
private class PolicyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AlertPolicySync.PLAYBACK_STATE_ACTION) {
playbackReady = intent.getBooleanExtra(AlertPolicySync.EXTRA_PLAYBACK_READY, false)
Log.i(TAG, "Alert playback listener ready=$playbackReady")
return
}
val payload = intent.getByteArrayExtra(AlertPolicySync.EXTRA_CONFIGURATION) ?: return
if (payload.size > MAX_CONFIGURATION_BYTES) return
configuration = runCatching { AlertConfigurationJson.decode(payload) }
.onFailure { Log.w(TAG, "Rejected malformed alert policy snapshot", it) }
.getOrNull() ?: return
Log.i(TAG, "Updated fail-closed alert policy cache")
}
}
private const val TAG = "NotificationsMaster"
private const val MAX_CONFIGURATION_BYTES = 64 * 1024
private const val MAX_ATTEMPTS = 10
private const val RETRY_DELAY_MILLIS = 1_000L
}
@@ -0,0 +1,102 @@
package se.ajpanton.notificationsmaster.module
import android.app.Notification
import android.os.Build
import android.service.notification.StatusBarNotification
import android.util.Log
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
import se.ajpanton.notificationsmaster.alerts.AlertEvent
import se.ajpanton.notificationsmaster.alerts.AlertSource
import se.ajpanton.notificationsmaster.capture.NotificationContents
/** Rule-relevant data copied from a system-server notification before enqueue. */
internal data class SystemNotificationEvent(
val packageName: String,
val title: String?,
val body: String?,
val sender: String?,
val channelId: String?,
val isGroupSummary: Boolean,
val isOngoing: Boolean,
) {
fun asAlertEvent(source: AlertSource = AlertSource.NOTIFICATION_POST) =
AlertEvent(packageName, source, title, body, sender)
companion object {
fun fromNotification(packageName: String, notification: Notification): SystemNotificationEvent {
val contents = NotificationContents.alertContents(notification)
return SystemNotificationEvent(
packageName = packageName,
title = contents.title,
body = contents.body,
sender = contents.sender,
channelId = notification.channelId,
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
isOngoing = notification.flags and Notification.FLAG_ONGOING_EVENT != 0,
)
}
fun fromStatusBarNotification(sbn: StatusBarNotification): SystemNotificationEvent =
fromNotification(sbn.packageName, sbn.notification)
}
}
/**
* Version-specific, fail-open system-server boundary.
*
* It intentionally does not expose [Notification] itself, so later policy code
* receives a small immutable snapshot rather than a mutable framework object.
*/
internal class SystemNotificationBridge(
private val framework: XposedInterface,
private val observer: (SystemNotificationEvent) -> Unit,
) {
fun install(classLoader: ClassLoader) {
if (Build.VERSION.SDK_INT !in SUPPORTED_SDKS) {
Log.i(TAG, "Notification bridge is inactive on API " + Build.VERSION.SDK_INT)
return
}
val managerClass = Class.forName(NOTIFICATION_MANAGER_SERVICE, false, classLoader)
val method = managerClass.declaredMethods.singleOrNull(::isSupportedEnqueueMethod)
if (method == null) {
Log.w(TAG, "Notification bridge method shape was not found; leaving Android unchanged")
return
}
val notificationIndex = method.parameterTypes.indexOf(Notification::class.java)
val packageIndex = method.parameterTypes.indexOfFirst { it == String::class.java }
if (notificationIndex < 0 || packageIndex < 0 || packageIndex > notificationIndex) {
Log.w(TAG, "Notification bridge method arguments were unexpected; leaving Android unchanged")
return
}
method.isAccessible = true
framework.hook(method).intercept { chain ->
eventFrom(chain.args, packageIndex, notificationIndex)?.let(observer)
chain.proceed()
}
Log.i(TAG, "Installed API " + Build.VERSION.SDK_INT + " fail-open notification bridge")
}
private fun eventFrom(
args: List<Any?>,
packageIndex: Int,
notificationIndex: Int,
): SystemNotificationEvent? {
val packageName = args.getOrNull(packageIndex) as? String ?: return null
val notification = args.getOrNull(notificationIndex) as? Notification ?: return null
return SystemNotificationEvent.fromNotification(packageName, notification)
}
private companion object {
const val TAG = "NotificationsMaster"
const val NOTIFICATION_MANAGER_SERVICE = "com.android.server.notification.NotificationManagerService"
const val ENQUEUE_NOTIFICATION = "enqueueNotificationInternal"
val SUPPORTED_SDKS = 34..36
fun isSupportedEnqueueMethod(method: Method): Boolean =
method.name == ENQUEUE_NOTIFICATION &&
method.returnType == Boolean::class.javaPrimitiveType &&
method.parameterTypes.any { it.name.endsWith("PostNotificationTracker") } &&
method.parameterTypes.count { it == Notification::class.java } == 1
}
}
@@ -0,0 +1,46 @@
package se.ajpanton.notificationsmaster.module
import android.content.Context
import java.lang.reflect.Field
import java.lang.reflect.Method
internal fun findField(type: Class<*>, vararg names: String): Field? {
var current: Class<*>? = type
while (current != null) {
names.firstNotNullOfOrNull { runCatching { current.getDeclaredField(it) }.getOrNull() }?.let {
it.isAccessible = true
return it
}
current = current.superclass
}
return null
}
internal fun memberAccessor(method: Method?, field: Field?): (Any) -> Any? {
method?.isAccessible = true
field?.isAccessible = true
return if (method != null) ({ method.invoke(it) }) else ({ field!!.get(it) })
}
internal fun requireField(type: Class<*>, name: String) =
findField(type, name) ?: error("${type.name}.$name is unavailable")
internal class IconStateAccess(type: Class<*>) {
private val clampedAmount = requireField(type, "clampedAppearAmount")
private val appearAmount = requireField(type, "iconAppearAmount")
private val alpha = requireField(type, "mAlpha")
private val hidden = requireField(type, "hidden")
private val visibleState = requireField(type, "visibleState")
fun hide(state: Any) {
clampedAmount.setFloat(state, 0f)
appearAmount.setFloat(state, 0f)
alpha.setFloat(state, 0f)
hidden.setBoolean(state, true)
visibleState.setInt(state, 2)
}
}
internal fun currentApplicationContext(): Context? = runCatching {
Class.forName("android.app.ActivityThread").getMethod("currentApplication").invoke(null) as? Context
}.getOrNull()
@@ -0,0 +1,32 @@
package se.ajpanton.notificationsmaster.module
import android.app.Notification
import android.os.Bundle
import android.service.notification.StatusBarNotification
import se.ajpanton.notificationsmaster.visibility.VisibilityNotification
internal object VisibilityNotificationExtractor {
fun from(sbn: StatusBarNotification): VisibilityNotification {
val notification = sbn.notification
val extras = notification.extras ?: Bundle.EMPTY
val messages = listOf(Notification.EXTRA_HISTORIC_MESSAGES, Notification.EXTRA_MESSAGES).flatMap { key ->
Notification.MessagingStyle.Message.getMessagesFromBundleArray(
extras.getParcelableArray(key, Bundle::class.java),
).map { message ->
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ")
}
}
return VisibilityNotification(
packageName = sbn.packageName,
key = sbn.key,
title = extras.text(Notification.EXTRA_TITLE),
text = extras.text(Notification.EXTRA_TEXT),
bigText = extras.text(Notification.EXTRA_BIG_TEXT),
subtext = extras.text(Notification.EXTRA_SUB_TEXT),
messages = messages,
channelId = notification.channelId,
)
}
private fun Bundle.text(key: String) = getCharSequence(key)?.toString()
}
@@ -0,0 +1,63 @@
package se.ajpanton.notificationsmaster.module
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicy
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyJson
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
internal object VisibilityPolicySync {
const val UPDATE_ACTION = "se.ajpanton.notificationsmaster.UPDATE_VISIBILITY_POLICY"
const val REQUEST_ACTION = "se.ajpanton.notificationsmaster.REQUEST_VISIBILITY_POLICY"
const val EXTRA_POLICY = "policy"
const val EXTRA_REQUESTER = "requester"
const val PERMISSION = AlertPolicySync.PERMISSION
const val APP_PACKAGE = "se.ajpanton.notificationsmaster"
const val REQUEST_RECEIVER = "$APP_PACKAGE.module.VisibilityPolicyRequestReceiver"
const val MAX_POLICY_BYTES = 128 * 1024
val TARGET_PACKAGES = setOf("com.android.systemui", "com.samsung.android.app.aodservice")
fun publish(context: Context, policy: VisibilityPolicy) {
val payload = VisibilityPolicyJson.encode(policy)
if (payload.size > MAX_POLICY_BYTES) {
Log.w(TAG, "Visibility policy exceeds the process-cache limit; allowing all notifications")
return
}
TARGET_PACKAGES.forEach { target ->
context.sendBroadcast(Intent(UPDATE_ACTION).setPackage(target).putExtra(EXTRA_POLICY, payload))
}
Log.i(TAG, "Published visibility policy generation ${policy.generation}")
}
fun publishStored(context: Context) {
val policy = runCatching { VisibilityPolicyStore(context).load() }
.onFailure { Log.e(TAG, "Stored visibility policy is unreadable; allowing all notifications", it) }
.getOrElse { VisibilityPolicy(enabled = false) }
publish(context, policy)
}
private const val TAG = "NotificationsMaster"
}
class VisibilityPolicyRequestReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != VisibilityPolicySync.REQUEST_ACTION) return
val requester = intent.getParcelableExtra(VisibilityPolicySync.EXTRA_REQUESTER, PendingIntent::class.java)
val creatorPackages = requester?.let {
context.packageManager.getPackagesForUid(it.creatorUid).orEmpty().toSet()
}.orEmpty()
val creatorPackage = requester?.creatorPackage
if (creatorPackage !in VisibilityPolicySync.TARGET_PACKAGES ||
creatorPackage !in creatorPackages
) {
Log.w(TAG, "Rejected visibility-policy request without a trusted identity")
return
}
VisibilityPolicySync.publishStored(context)
}
private companion object { const val TAG = "NotificationsMaster" }
}
@@ -0,0 +1,6 @@
package se.ajpanton.notificationsmaster.module
internal interface VisibilitySurfaceBackend {
fun install(classLoader: ClassLoader): Boolean
fun onPolicyChanged()
}
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
import android.content.Context
import androidx.core.content.edit
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
data class ListedApp(
val label: String,
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
import android.content.Context
import androidx.core.content.edit
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
import android.content.Context
import androidx.core.content.edit
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
import android.content.Context
import androidx.core.content.edit
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
enum class LogField { TIMESTAMP, APP_NAME, PACKAGE_NAME, ACTION, CONTENTS }
enum class TimestampZone { EVENT_LOCAL, LOCAL_NOW, UTC }
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
import android.content.Context
import androidx.core.content.edit
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
import android.content.Context
import androidx.core.content.edit
@@ -28,11 +28,7 @@ class LoggingRuleStore(context: Context) {
private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix"
private fun updateListenerComponent() {
NotificationListenerComponentController.update(
appContext,
LoggingType.entries.associateWith(::ruleFor),
PerAppEventSettingsStore(appContext).hasEnabledEventOverride(),
)
NotificationListenerComponentController.synchronize(appContext)
}
private companion object {
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
enum class LoggingType {
APPEARING,
@@ -0,0 +1,76 @@
package se.ajpanton.notificationsmaster.settings
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import android.service.notification.NotificationListenerService
import android.util.Log
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
import se.ajpanton.notificationsmaster.module.AlertPolicyBootReceiver
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
/**
* Notification listeners are re-bound by Android after boot when their component
* is enabled and the user granted notification access. Disabling the component
* is therefore both the no-work battery mode and the no-start-on-boot mode.
*/
internal object NotificationListenerComponentController {
fun synchronize(context: Context) {
val applicationContext = context.applicationContext
val rules = LoggingRuleStore(applicationContext)
update(
applicationContext,
LoggingType.entries.associateWith(rules::ruleFor),
PerAppEventSettingsStore(applicationContext).hasEnabledEventOverride(),
AlertConfigurationStore(applicationContext).load().needsListener,
VisibilityPolicyStore(applicationContext).load().hasRules,
)
}
fun update(
context: Context,
rules: Map<LoggingType, LoggingRule>,
hasEnabledEventOverride: Boolean = false,
hasEnabledAlertRule: Boolean = false,
hasVisibilityRules: Boolean = false,
) {
val applicationContext = context.applicationContext
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
val packageManager = applicationContext.packageManager
val desired = if (NotificationListenerPolicy.shouldRun(
rules,
hasEnabledEventOverride,
hasEnabledAlertRule,
hasVisibilityRules,
)) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
if (packageManager.getComponentEnabledSetting(component) != desired) {
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
}
val bootReceiver = ComponentName(applicationContext, AlertPolicyBootReceiver::class.java)
val bootReceiverState = if (hasEnabledAlertRule || hasVisibilityRules) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
if (packageManager.getComponentEnabledSetting(bootReceiver) != bootReceiverState) {
packageManager.setComponentEnabledSetting(bootReceiver, bootReceiverState, PackageManager.DONT_KILL_APP)
}
if (desired == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
try {
// Data clearing and OEM listener management can leave an enabled
// component without a live binding. Ask Android to reconnect on
// every enabled-rule synchronization, not just a state change.
NotificationListenerService.requestRebind(component)
} catch (error: SecurityException) {
Log.w(TAG, "Notification access is not granted yet; Android will bind after the user grants it", error)
}
}
}
private const val TAG = "NotificationListenerControl"
}
@@ -1,9 +1,14 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
/** The listener is useful only when at least one event type is enabled. */
object NotificationListenerPolicy {
fun shouldRun(
rules: Map<LoggingType, LoggingRule>,
hasEnabledEventOverride: Boolean = false,
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } || hasEnabledEventOverride
hasEnabledAlertRule: Boolean = false,
hasVisibilityRules: Boolean = false,
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } ||
hasEnabledEventOverride ||
hasEnabledAlertRule ||
hasVisibilityRules
}
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
object NotificationRuleEvaluator {
fun allows(filter: AppFilterSettings, packageName: String): Boolean {
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
import android.content.Context
import androidx.core.content.edit
@@ -52,8 +52,7 @@ class PerAppEventSettingsStore(context: Context) {
}
private fun updateListenerComponent() {
val rules = LoggingType.entries.associateWith { LoggingRuleStore(appContext).ruleFor(it) }
NotificationListenerComponentController.update(appContext, rules, hasEnabledEventOverride())
NotificationListenerComponentController.synchronize(appContext)
}
private fun key(packageName: String) = "$KEY_PREFIX$packageName"
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
import android.content.Context
import androidx.core.content.edit
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.settings
package se.ajpanton.notificationsmaster.settings
import android.content.Context
import android.text.format.DateFormat as AndroidDateFormat
@@ -0,0 +1,34 @@
package se.ajpanton.notificationsmaster.visibility
data class CardsGridRows(
val rowSizes: List<Int>,
val showsOverflow: Boolean,
) {
val visibleSlots get() = rowSizes.sum()
}
object CardsGridLayout {
fun arrange(itemCount: Int, settings: CardsGridSettings, widthCapacity: Int? = null): CardsGridRows {
require(itemCount >= 0)
val perRow = if (settings.limitToScreenWidth && widthCapacity != null) {
minOf(settings.maxIconsPerRow, widthCapacity.coerceAtLeast(0))
} else {
settings.maxIconsPerRow
}
val capacity = perRow * settings.maxRows
if (itemCount == 0 || capacity == 0) return CardsGridRows(emptyList(), itemCount > 0)
val overflow = itemCount > capacity
val slots = minOf(itemCount, capacity)
val rows = minOf(settings.maxRows, (slots + perRow - 1) / perRow)
if (!settings.evenDistribution || rows == 1) {
var remaining = slots
return CardsGridRows(List(rows) {
minOf(perRow, remaining).also { remaining -= it }
}, overflow)
}
val base = slots / rows
val remainder = slots % rows
return CardsGridRows(List(rows) { base + if (it < remainder) 1 else 0 }, overflow)
}
}
@@ -0,0 +1,127 @@
package se.ajpanton.notificationsmaster.visibility
import com.google.re2j.Pattern
enum class NotificationSurface { AOD, LOCKSCREEN_COLLAPSED, UNLOCKED_STATUSBAR, DRAWER }
data class CardsGridSettings(
val maxRows: Int = 1,
val maxIconsPerRow: Int = 5,
val evenDistribution: Boolean = true,
val limitToScreenWidth: Boolean = true,
val iconHeightPercent: Int,
) {
init {
require(maxRows in 0..5)
require(maxIconsPerRow in 1..15)
require(iconHeightPercent in 10..100)
}
companion object {
val LOCKSCREEN_DEFAULT = CardsGridSettings(iconHeightPercent = 59)
val AOD_DEFAULT = CardsGridSettings(iconHeightPercent = 65)
}
}
data class VisibilityExceptionRule(
val pattern: String,
val blockedSurfaces: Set<NotificationSurface>,
) {
init { require(pattern.isNotBlank()) }
fun hasValidPattern() = runCatching { Pattern.compile(pattern) }.isSuccess
}
data class AppVisibilityPolicy(
val packageName: String,
val blockedSurfaces: Set<NotificationSurface> = emptySet(),
val exceptions: List<VisibilityExceptionRule> = emptyList(),
) {
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,
val apps: List<AppVisibilityPolicy> = emptyList(),
val unlockedIconLimit: Int? = null,
val lockscreenCards: CardsGridSettings = CardsGridSettings.LOCKSCREEN_DEFAULT,
val aodCards: CardsGridSettings = CardsGridSettings.AOD_DEFAULT,
val systemUiMisc: SystemUiMiscSettings = SystemUiMiscSettings(),
) {
init {
require(generation >= 0)
require(apps.map { it.packageName }.distinct().size == apps.size)
require(unlockedIconLimit == null || unlockedIconLimit in 1..1000)
}
val hasRules get() = enabled && (unlockedIconLimit != null ||
apps.any { it.blockedSurfaces.isNotEmpty() || it.exceptions.isNotEmpty() })
}
data class VisibilityNotification(
val packageName: String,
val key: String,
val title: String? = null,
val text: String? = null,
val bigText: String? = null,
val subtext: String? = null,
val messages: List<String> = emptyList(),
val channelId: String? = null,
)
class CompiledVisibilityPolicy(policy: VisibilityPolicy) {
val generation = policy.generation
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(
app.blockedSurfaces,
app.exceptions.map { CompiledRule(Pattern.compile(it.pattern), it.blockedSurfaces) },
)
}
fun isBlocked(notification: VisibilityNotification, surface: NotificationSurface): Boolean {
if (!enabled) return false
val policy = apps[notification.packageName] ?: return false
val matchTexts = notification.matchTexts()
val exception = policy.exceptions.firstOrNull { rule ->
matchTexts.any { rule.pattern.matcher(it).find() }
}
return surface in (exception?.blockedSurfaces ?: policy.blockedSurfaces)
}
fun hasExceptions(packageName: String) = apps[packageName]?.exceptions?.isNotEmpty() == true
private fun VisibilityNotification.matchTexts() = buildList {
add(packageName)
add(key)
listOf(title, text, bigText, subtext, channelId).filterNotNullTo(this)
addAll(messages)
}.map(String::trim).filter(String::isNotEmpty).let { fields ->
fields + fields.joinToString("\n")
}
private data class CompiledAppPolicy(
val blockedSurfaces: Set<NotificationSurface>,
val exceptions: List<CompiledRule>,
)
private data class CompiledRule(
val pattern: Pattern,
val blockedSurfaces: Set<NotificationSurface>,
)
companion object {
val ALLOW_ALL = CompiledVisibilityPolicy(VisibilityPolicy(enabled = false))
}
}
@@ -0,0 +1,85 @@
package se.ajpanton.notificationsmaster.visibility
import org.json.JSONArray
import org.json.JSONObject
internal object VisibilityPolicyJson {
const val SCHEMA_VERSION = 1
fun encode(policy: VisibilityPolicy): ByteArray = JSONObject()
.put("version", SCHEMA_VERSION)
.put("generation", policy.generation)
.put("enabled", policy.enabled)
.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()
fun decode(bytes: ByteArray): VisibilityPolicy {
val root = JSONObject(bytes.decodeToString())
require(root.getInt("version") == SCHEMA_VERSION)
return VisibilityPolicy(
generation = root.getLong("generation"),
enabled = root.getBoolean("enabled"),
apps = root.getJSONArray("apps").map { (it as JSONObject).toAppPolicy() },
unlockedIconLimit = if (root.isNull("unlockedIconLimit")) null else root.getInt("unlockedIconLimit"),
lockscreenCards = root.optJSONObject("lockscreenCards")?.toCardsGridSettings()
?: 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)
.put("evenDistribution", evenDistribution)
.put("limitToScreenWidth", limitToScreenWidth)
.put("iconHeightPercent", iconHeightPercent)
private fun JSONObject.toCardsGridSettings() = CardsGridSettings(
maxRows = getInt("maxRows"),
maxIconsPerRow = getInt("maxIconsPerRow"),
evenDistribution = getBoolean("evenDistribution"),
limitToScreenWidth = getBoolean("limitToScreenWidth"),
iconHeightPercent = getInt("iconHeightPercent"),
)
private fun AppVisibilityPolicy.toJson() = JSONObject()
.put("packageName", packageName)
.put("blockedSurfaces", JSONArray(blockedSurfaces.map { it.name }))
.put("exceptions", JSONArray().apply { exceptions.forEach { put(it.toJson()) } })
private fun VisibilityExceptionRule.toJson() = JSONObject()
.put("pattern", pattern)
.put("blockedSurfaces", JSONArray(blockedSurfaces.map { it.name }))
private fun JSONObject.toAppPolicy() = AppVisibilityPolicy(
packageName = getString("packageName"),
blockedSurfaces = getJSONArray("blockedSurfaces").surfaces(),
exceptions = getJSONArray("exceptions").map { (it as JSONObject).toException() },
)
private fun JSONObject.toException() = VisibilityExceptionRule(
pattern = getString("pattern"),
blockedSurfaces = getJSONArray("blockedSurfaces").surfaces(),
)
private fun JSONArray.surfaces() = map { NotificationSurface.valueOf(it as String) }.toSet()
private fun <T> JSONArray.map(transform: (Any) -> T) = List(length()) { transform(get(it)) }
}

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