128 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
ajp_anton 9832b80e64 Release 1.1 2026-08-08 06:55:34 +00:00
ajp_anton 96bd5c4d17 Keep selected app names after clearing logs 2026-08-08 06:21:50 +00:00
ajp_anton d85f9050cc Clarify previous contents in edited logs 2026-08-08 06:05:50 +00:00
ajp_anton 653080090b Avoid suppressing message updates as progress 2026-08-08 06:00:56 +00:00
ajp_anton 6eaf2a8784 Capture later structured messaging updates 2026-08-08 04:33:16 +00:00
ajp_anton 5b8de63aac Protect locked previews and filter logs by app 2026-08-08 04:33:16 +00:00
ajp_anton 05d682601a Release 1.0 2026-07-28 13:02:22 +00:00
ajp_anton 02b4862294 Clarify timestamp defaults and app filter overrides 2026-07-28 04:35:47 +00:00
ajp_anton bb822736ab Separate dropdown popups from settings pages 2026-07-28 04:06:32 +00:00
ajp_anton 591527750d Label storage limits in MiB 2026-07-28 04:02:17 +00:00
ajp_anton 5f6f169d3e Clarify image support in log exports 2026-07-28 04:01:10 +00:00
ajp_anton a107c481a2 Release 0.2 2026-07-28 03:56:25 +00:00
ajp_anton 2a330cc831 Streamline UI updates and cleanup maintenance debt 2026-07-27 23:21:59 +00:00
ajp_anton c28144b9ed Keep recycled log list lint clean 2026-07-27 23:02:25 +00:00
ajp_anton 21a228a4f6 Recycle paged log rows 2026-07-27 23:01:51 +00:00
ajp_anton 66ab982a7e Page encrypted logs with cursors 2026-07-27 22:55:58 +00:00
ajp_anton 6467d1ab6c Use lifecycle-scoped IO for screen work 2026-07-27 22:55:09 +00:00
ajp_anton a86ea52006 Resolve concrete lint and backup configuration issues 2026-07-27 22:54:06 +00:00
ajp_anton f31a7c8efe Avoid inactive listener work and eager image encoding 2026-07-27 22:51:17 +00:00
ajp_anton ebb05acc02 Avoid duplicate BigText notification content 2026-07-27 22:32:28 +00:00
ajp_anton 6a41b922e3 Filter group summary cancellation logs 2026-07-27 22:08:25 +00:00
ajp_anton 6116c13e00 Add configurable timestamp date and clock formats 2026-07-27 22:06:47 +00:00
ajp_anton 125ba99af9 Highlight rows after collapsing logs 2026-07-27 21:57:32 +00:00
ajp_anton f97dad8fb2 Anchor log collapse at the current viewport 2026-07-27 21:53:10 +00:00
ajp_anton 68e780c205 Tighten gap between log columns 2026-07-27 21:41:47 +00:00
ajp_anton e2a04aa950 Tighten view logs horizontal insets 2026-07-27 21:41:36 +00:00
ajp_anton 94250286e6 Restyle log rows as a responsive table 2026-07-27 21:36:03 +00:00
ajp_anton 627fda6bf8 Restore log position after collapse layout 2026-07-27 21:26:09 +00:00
ajp_anton 2584aa77be Prevent panning before quick scale 2026-07-27 21:23:27 +00:00
ajp_anton 1145713584 Use Android quick scale for image zoom 2026-07-27 21:21:26 +00:00
ajp_anton 0f3b4397f3 Avoid conflicting double-tap zoom gestures 2026-07-27 21:15:26 +00:00
ajp_anton 2efd923f7f Improve full-screen image viewer gestures 2026-07-27 21:05:20 +00:00
ajp_anton 9f880c5f1c Request listener rebind on startup 2026-07-27 20:54:22 +00:00
ajp_anton 13a6c9c02e Restore listener after clearing app data 2026-07-27 20:43:00 +00:00
ajp_anton 976ca9596e Preserve log position when collapsing rows 2026-07-27 20:30:53 +00:00
ajp_anton 7db3a370e7 Expand long log metadata on tap 2026-07-27 18:16:29 +00:00
ajp_anton 83a71378fb Add full-screen notification image viewer 2026-07-27 17:43:42 +00:00
ajp_anton d09fb4fa09 Keep image markers after retention trimming 2026-07-27 17:24:15 +00:00
ajp_anton a72c6b9437 Stream notification log exports 2026-07-27 17:14:33 +00:00
ajp_anton 8f21874492 Add configurable chunked storage limits 2026-07-27 14:06:14 +00:00
ajp_anton dca86ae393 Clean stale per-app storage on startup 2026-07-27 13:51:10 +00:00
ajp_anton 1e4315d3dd Add per-app event logging overrides 2026-07-27 13:45:32 +00:00
ajp_anton 55057767c1 Clarify seen and checked app filtering 2026-07-27 13:27:08 +00:00
ajp_anton cad73e33a0 Simplify and organize settings pages 2026-07-27 12:59:26 +00:00
ajp_anton 3dc87b417a Improve grouped and messaging notification capture 2026-07-27 12:03:33 +00:00
ajp_anton 4d84ff98c7 Revert "Avoid duplicated MessagingStyle notification text"
This reverts commit 5c8cb814f6.
2026-07-27 01:25:20 +00:00
ajp_anton 5c8cb814f6 Avoid duplicated MessagingStyle notification text 2026-07-27 01:19:47 +00:00
ajp_anton 931ff5830f Add scroll-to-top control for logs 2026-07-27 01:17:40 +00:00
ajp_anton 607e89938e Show complete metadata in log pills 2026-07-26 23:53:14 +00:00
ajp_anton d190f4aee1 Outline compact log pills 2026-07-26 23:23:31 +00:00
ajp_anton 43905f72f0 Present logs in compact metadata and content pills 2026-07-26 23:22:37 +00:00
ajp_anton 3bc71cdc61 Hide logs behind authentication prompt 2026-07-26 15:15:45 +00:00
ajp_anton 6b93492a0e Declare biometric permission for app lock 2026-07-26 14:43:06 +00:00
ajp_anton d6868b6927 Load event app lists asynchronously 2026-07-26 14:41:37 +00:00
ajp_anton c5f3dade9f Simplify settings navigation and copy controls 2026-07-26 14:38:40 +00:00
ajp_anton 14a96046f7 Refresh logs and move log controls to settings 2026-07-26 14:37:20 +00:00
189 changed files with 12142 additions and 1648 deletions
+55 -8
View File
@@ -1,8 +1,8 @@
# Notification Log # Notifications Master
Notification Log keeps a private history of the notifications that appear on Notifications Master keeps a private history of your Android notifications and
your Android device. It works as a standard Android notification listener; it can play custom alerts for the ones that matter. It works normally without root
does not need root or an Xposed module. or an Xposed module.
## AI Assistance ## AI Assistance
@@ -19,6 +19,8 @@ decisions, physical device testing, and final behaviour are still manually revie
You can choose which event types to record, set app-specific allow/block lists, You can choose which event types to record, set app-specific allow/block lists,
and suppress routine progress or timer updates. The log view can be tailored, and suppress routine progress or timer updates. The log view can be tailored,
expanded for long entries, and exported as CSV, aligned text, or an HTML ZIP. expanded for long entries, and exported as CSV, aligned text, or an HTML ZIP.
Only the HTML ZIP includes saved notification images; CSV and aligned text use
an `[image]` marker instead.
## Privacy ## Privacy
@@ -28,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 custom notification content; this app records only what Android makes available
to a notification listener. to a notification listener.
When all logging types are disabled, the notification listener is disabled too: The optional lock protects log screens with your device biometric or credential
the app has no polling, scheduled job, or background service to run. 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 ## 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 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: Tested Android versions:
+14 -5
View File
@@ -34,15 +34,15 @@ if (releaseBuildRequested && !hasReleaseSigning) {
} }
android { android {
namespace = "se.ajpanton.notificationlog" namespace = "se.ajpanton.notificationsmaster"
compileSdk = 36 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "se.ajpanton.notificationlog" applicationId = "se.ajpanton.notificationsmaster"
minSdk = 34 minSdk = 34
targetSdk = 36 targetSdk = 36
versionCode = project.findProperty("notificationLogVersionCode")?.toString()?.toIntOrNull() ?: 1 versionCode = project.findProperty("notificationsMasterVersionCode")?.toString()?.toIntOrNull() ?: 1
versionName = project.findProperty("notificationLogVersionName")?.toString() ?: "0.1" versionName = project.findProperty("notificationsMasterVersionName")?.toString() ?: "0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -88,7 +88,16 @@ dependencies {
implementation(libs.fragment.ktx) implementation(libs.fragment.ktx)
implementation(libs.material) implementation(libs.material)
implementation(libs.drawerlayout) implementation(libs.drawerlayout)
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") 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(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))
}
}
+35 -4
View File
@@ -1,14 +1,31 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <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
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application <application
android:name=".NotificationsMasterApplication"
android:allowBackup="false" android:allowBackup="false"
android:icon="@drawable/ic_launcher" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.NotificationLog"> android:theme="@style/Theme.NotificationsMaster">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -28,6 +45,20 @@
<action android:name="android.service.notification.NotificationListenerService" /> <action android:name="android.service.notification.NotificationListenerService" />
</intent-filter> </intent-filter>
</service> </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> </application>
</manifest> </manifest>
@@ -1,149 +0,0 @@
package se.ajpanton.notificationlog
import android.os.Bundle
import android.view.MotionEvent
import android.view.View
import android.widget.CheckBox
import android.widget.LinearLayout
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import se.ajpanton.notificationlog.databinding.FragmentEventSettingsBinding
import se.ajpanton.notificationlog.settings.AppRuleMode
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.AppListItem
import se.ajpanton.notificationlog.settings.AppListOrdering
import se.ajpanton.notificationlog.settings.ListedApp
import se.ajpanton.notificationlog.capture.SeenApps
class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
private var binding: FragmentEventSettingsBinding? = null
private lateinit var type: LoggingType
private lateinit var store: LoggingRuleStore
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentEventSettingsBinding.bind(view)
type = requireArguments().getSerializable(ARG_TYPE, LoggingType::class.java)!!
store = LoggingRuleStore(requireContext())
binding!!.pageTitle.setText(requireArguments().getInt(ARG_TITLE))
bindRule()
binding!!.appListRefresh.setOnRefreshListener(::refreshAppList)
var touchStartY = 0f
val scroll = binding!!.appScroll
scroll.setOnTouchListener { _, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> touchStartY = event.y
MotionEvent.ACTION_UP -> {
val atBottom = scroll.scrollY >= (scroll.getChildAt(0).height - scroll.height).coerceAtLeast(0)
if (atBottom && event.y < touchStartY) {
refreshAppList()
}
}
}
false
}
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun refreshAppList() {
val currentBinding = binding ?: return
currentBinding.appListRefresh.isRefreshing = true
currentBinding.appListRefresh.post {
if (binding === currentBinding) {
reloadAppList()
currentBinding.appListRefresh.isRefreshing = false
}
}
}
private fun bindRule() {
val rule = store.ruleFor(type)
listOf(
binding!!.masterToggle,
binding!!.appRuleToggle,
binding!!.onlySeenToggle,
binding!!.seenFirstToggle,
binding!!.routineUpdatesToggle,
).forEach { it.setOnCheckedChangeListener(null) }
binding!!.masterToggle.isChecked = rule.enabled
binding!!.appRuleToggle.isChecked = rule.appRuleMode == AppRuleMode.WHITELIST
binding!!.onlySeenToggle.isChecked = rule.onlySeenApps
binding!!.seenFirstToggle.isChecked = rule.seenAppsFirst
binding!!.seenFirstToggle.isEnabled = !rule.onlySeenApps
binding!!.routineUpdatesToggle.visibility = if (type == LoggingType.EDITS) View.VISIBLE else View.GONE
binding!!.routineUpdatesToggle.isChecked = rule.ignoreRoutineUpdates
updateRuleLabels()
binding!!.masterToggle.setOnCheckedChangeListener { _, checked -> save { copy(enabled = checked) } }
binding!!.appRuleToggle.setOnCheckedChangeListener { _, checked -> save { copy(appRuleMode = if (checked) AppRuleMode.WHITELIST else AppRuleMode.BLACKLIST) } }
binding!!.onlySeenToggle.setOnCheckedChangeListener { _, checked -> save { copy(onlySeenApps = checked) } }
binding!!.seenFirstToggle.setOnCheckedChangeListener { _, checked -> save { copy(seenAppsFirst = checked) } }
binding!!.routineUpdatesToggle.setOnCheckedChangeListener { _, checked -> save { copy(ignoreRoutineUpdates = checked) } }
reloadAppList()
}
private fun save(change: se.ajpanton.notificationlog.settings.LoggingRule.() -> se.ajpanton.notificationlog.settings.LoggingRule) {
store.save(type, store.ruleFor(type).change())
bindRule()
}
private fun updateRuleLabels() {
val rule = store.ruleFor(type)
binding!!.appRuleToggle.text = if (rule.appRuleMode == AppRuleMode.WHITELIST) "Whitelist chosen" else "Blacklist chosen"
binding!!.onlySeenToggle.text = if (rule.onlySeenApps) "Show only seen apps" else "Show all apps"
}
private fun reloadAppList() {
val container = binding!!.appList
container.removeAllViews()
val rule = store.ruleFor(type)
val seen = SeenApps.snapshot()
val apps = requireContext().packageManager.getInstalledApplications(0)
.map { info ->
ListedApp(
label = requireContext().packageManager.getApplicationLabel(info).toString(),
packageName = info.packageName,
seen = info.packageName in seen,
selected = info.packageName in rule.selectedPackages,
)
}
AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst).forEach { item ->
container.addView(if (item is AppListItem.App) appView(item.value) else separator())
}
}
private fun appView(row: ListedApp): View = LinearLayout(requireContext()).apply {
orientation = LinearLayout.HORIZONTAL
val checkbox = CheckBox(context).apply { isChecked = row.selected }
checkbox.setOnCheckedChangeListener { _, checked ->
val current = store.ruleFor(type)
val packages = current.selectedPackages.toMutableSet().apply {
if (checked) add(row.packageName) else remove(row.packageName)
}
store.save(type, current.copy(selectedPackages = packages))
// Deliberately do not reload: an unchecked unseen row must remain reachable.
}
addView(checkbox)
addView(LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
addView(TextView(context).apply { text = row.label; textSize = 16f; setTypeface(typeface, 1) })
addView(TextView(context).apply { text = row.packageName; textSize = 12f })
})
}
private fun separator() = View(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1).apply { setMargins(0, 12, 0, 12) }
setBackgroundColor(0x33000000)
}
companion object {
private const val ARG_TITLE = "title"
private const val ARG_TYPE = "type"
fun newInstance(@StringRes title: Int, type: LoggingType) = EventSettingsFragment().apply {
arguments = Bundle().apply { putInt(ARG_TITLE, title); putSerializable(ARG_TYPE, type) }
}
}
}
@@ -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,224 +0,0 @@
package se.ajpanton.notificationlog
import android.os.Bundle
import android.content.ClipData
import android.net.Uri
import android.view.View
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
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.TimestampZone
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.AppLockStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.export.LogExporter
import java.util.zip.ZipOutputStream
class SettingsFragment : Fragment(R.layout.fragment_settings) {
private var binding: FragmentSettingsBinding? = null
private var selectedCopyTargets = emptySet<LoggingType>()
private var pendingExport: ExportFormat? = null
private val createDocument = registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
uri?.let(::writeExport)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentSettingsBinding.bind(view)
val store = LogViewSettingsStore(requireContext())
var settings = store.load()
settings.order.forEach { field ->
binding!!.fieldToggles.addView(SwitchMaterial(requireContext()).apply {
text = when (field) {
LogField.TIMESTAMP -> "Timestamp"; LogField.APP_NAME -> "App name"
LogField.PACKAGE_NAME -> "Package name"; LogField.ACTION -> "What the app did"
LogField.CONTENTS -> "Contents of the notification"
}
isChecked = field in settings.visibleFields
tag = field
setOnLongClickListener {
startDragAndDrop(ClipData.newPlainText("field", field.name), View.DragShadowBuilder(this), this, 0)
true
}
setOnCheckedChangeListener { _, checked ->
settings = settings.copy(visibleFields = settings.visibleFields.toMutableSet().apply {
if (checked) add(field) else remove(field)
})
store.save(settings)
}
})
}
binding!!.fieldToggles.setOnDragListener { _, event ->
if (event.action != android.view.DragEvent.ACTION_DROP) return@setOnDragListener true
val source = event.localState as? View ?: return@setOnDragListener false
val container = binding!!.fieldToggles
val index = (0 until container.childCount).firstOrNull { event.y < container.getChildAt(it).bottom } ?: container.childCount
container.removeView(source); container.addView(source, index.coerceAtMost(container.childCount))
settings = settings.copy(order = (0 until container.childCount).map { container.getChildAt(it).tag as LogField })
store.save(settings)
true
}
val labels = listOf("Current local timezone", "UTC", "Local timezone when event happened")
binding!!.timezone.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, labels)
binding!!.timezone.setSelection(TimestampZone.entries.indexOf(settings.timestampZone))
binding!!.timezone.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) {
settings = settings.copy(timestampZone = TimestampZone.entries[position]); store.save(settings)
}
}
setupCopyControls()
setupEventMasterToggles()
binding!!.exportLogs.setOnClickListener { confirmExport() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
val lockStore = AppLockStore(requireContext())
binding!!.appLock.isChecked = lockStore.enabled
binding!!.appLock.setOnCheckedChangeListener { _, enabled -> lockStore.enabled = enabled }
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
override fun onResume() {
super.onResume()
refreshEventMasterToggles()
}
private fun setupEventMasterToggles() {
val container = binding!!.eventMasterToggles
LoggingType.entries.forEach { type ->
container.addView(SwitchMaterial(requireContext()).apply {
text = "${type.label()} logging"
tag = type
setOnCheckedChangeListener { _, enabled ->
LoggingRuleStore(requireContext()).save(type, LoggingRuleStore(requireContext()).ruleFor(type).copy(enabled = enabled))
}
})
}
refreshEventMasterToggles()
}
private fun refreshEventMasterToggles() {
val store = LoggingRuleStore(requireContext())
val container = binding?.eventMasterToggles ?: return
for (index in 0 until container.childCount) {
val toggle = container.getChildAt(index) as SwitchMaterial
toggle.setOnCheckedChangeListener(null)
toggle.isChecked = store.ruleFor(toggle.tag as LoggingType).enabled
toggle.setOnCheckedChangeListener { _, enabled ->
val type = toggle.tag as LoggingType
store.save(type, store.ruleFor(type).copy(enabled = enabled))
}
}
}
private fun setupCopyControls() {
val labels = listOf("From") + LoggingType.entries.map { it.label() }
binding!!.copyFrom.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, labels)
binding!!.copyFrom.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) {
selectedCopyTargets = emptySet()
binding!!.copyTo.isEnabled = position > 0
updateCopyButton()
}
}
binding!!.copyTo.setOnClickListener { chooseCopyTargets(LoggingType.entries[binding!!.copyFrom.selectedItemPosition - 1]) }
binding!!.copyEventSettings.setOnClickListener {
LoggingRuleStore(requireContext()).copy(LoggingType.entries[binding!!.copyFrom.selectedItemPosition - 1], selectedCopyTargets)
}
}
private fun chooseCopyTargets(source: LoggingType) {
val targets = LoggingType.entries.filterNot { it == source }
val checked = BooleanArray(targets.size) { targets[it] in selectedCopyTargets }
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle("Copy ${source.label()} settings to")
.setMultiChoiceItems(targets.map { it.label() }.toTypedArray(), checked) { _, index, selected -> checked[index] = selected }
.setNegativeButton("Cancel", null)
.setNeutralButton("All", null)
.setPositiveButton("Done") { _, _ ->
selectedCopyTargets = targets.filterIndexed { index, _ -> checked[index] }.toSet()
updateCopyButton()
}
.create()
dialog.setOnShowListener {
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener {
val selectAll = checked.any { !it }
checked.indices.forEach { index ->
checked[index] = selectAll
dialog.listView.setItemChecked(index, selectAll)
}
}
}
dialog.show()
}
private fun updateCopyButton() { binding?.copyEventSettings?.isEnabled = selectedCopyTargets.isNotEmpty() }
private fun confirmClearLogs() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Clear logs?")
.setMessage("This permanently removes every stored notification log and copied image.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Clear") { _, _ -> EncryptedNotificationLogStore(requireContext()).clear() }
.show()
}
private fun chooseExportFormat() {
MaterialAlertDialogBuilder(requireContext())
.setItems(arrayOf("CSV", "Formatted text", "HTML ZIP")) { _, which ->
pendingExport = ExportFormat.entries[which]
createDocument.launch("notification-log.${pendingExport!!.extension}")
}
.show()
}
private fun confirmExport() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Export unencrypted logs?")
.setMessage("The exported file will not be encrypted. Anyone with access to its destination can read the selected log fields.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Continue") { _, _ -> chooseExportFormat() }
.show()
}
private fun writeExport(uri: Uri) {
val format = pendingExport ?: return
val context = requireContext().applicationContext
Thread {
val entries = EncryptedNotificationLogStore(context).readAll()
val settings = LogViewSettingsStore(context).load()
context.contentResolver.openOutputStream(uri)?.use { output ->
when (format) {
ExportFormat.CSV -> output.write(LogExporter.csv(entries, settings).encodeToByteArray())
ExportFormat.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray())
ExportFormat.HTML_ZIP -> ZipOutputStream(output).use { zip ->
zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html"))
val imageStore = EncryptedImageStore(context)
val imageEntries = entries.filter { it.imageId != null && LogField.CONTENTS in settings.visibleFields }
val exportedImages = imageEntries.mapNotNull { entry ->
imageStore.read(entry.imageId!!)?.let { entry.imageId to it }
}.toMap()
zip.write(LogExporter.html(entries, settings) { entry ->
entry.imageId?.takeIf(exportedImages::containsKey)?.let { "images/$it.png" }
}.encodeToByteArray())
zip.closeEntry()
exportedImages.forEach { (imageId, image) ->
zip.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png"))
zip.write(image)
zip.closeEntry()
}
}
}
}
}.start()
}
private fun LoggingType.label() = name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase)
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") }
}
@@ -1,246 +0,0 @@
package se.ajpanton.notificationlog
import android.content.ComponentName
import android.content.Intent
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.text.TextUtils
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import se.ajpanton.notificationlog.capture.NotificationCaptureService
import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding
import se.ajpanton.notificationlog.export.LogExporter
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.TimestampZone
import java.text.DateFormat
import java.util.Date
import java.util.zip.ZipOutputStream
class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private var binding: FragmentViewLogsBinding? = null
private var pendingExport: ExportFormat? = null
private val expandedIds = mutableSetOf<String>()
private val createDocument = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream"),
) { uri -> uri?.let(::writeExport) }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentViewLogsBinding.bind(view)
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
binding!!.exportLogs.setOnClickListener { confirmExport() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
loadLogs()
}
override fun onResume() {
super.onResume()
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access"
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
private fun loadLogs() {
val context = requireContext().applicationContext
Thread {
val imageStore = EncryptedImageStore(context)
val rows = EncryptedNotificationLogStore(context).readAll()
.sortedByDescending { it.recordedAtEpochMillis }
.map { entry -> LogRow(entry, entry.imageId?.let(imageStore::read)) }
activity?.runOnUiThread { binding?.let { render(it, rows) } }
}.start()
}
private fun render(view: FragmentViewLogsBinding, rows: List<LogRow>) {
view.logRows.removeAllViews()
view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE
val settings = LogViewSettingsStore(requireContext()).load()
rows.forEach { row -> view.logRows.addView(logRowView(row, settings)) }
}
private fun logRowView(row: LogRow, settings: LogViewSettings): View = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(12)
}
isClickable = true
isFocusable = true
fun renderExpanded(expanded: Boolean) {
removeAllViews()
if (!expanded) {
addView(TextView(context).apply {
text = values(row.entry, settings).joinToString(" · ")
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setPadding(0, dp(8), 0, dp(8))
})
return
}
settings.order.filter { it in settings.visibleFields }.forEach { field ->
val value = fieldValue(field, row.entry, settings).takeUnless { field == LogField.CONTENTS && it == "[image]" }
if (!value.isNullOrEmpty()) {
addView(TextView(context).apply {
text = value
setLineSpacing(0f, 0.92f)
setPadding(0, dp(1), 0, dp(1))
})
}
}
if (row.entry.action == NotificationAction.EDITED && !row.entry.previousContents.isNullOrEmpty()) {
addView(TextView(context).apply {
text = "Previous: ${row.entry.previousContents}"
setLineSpacing(0f, 0.92f)
setPadding(0, dp(1), 0, dp(1))
})
}
row.imageBytes?.let { bytes ->
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { bitmap ->
addView(ImageView(context).apply {
setImageBitmap(bitmap)
adjustViewBounds = true
maxHeight = dp(240)
contentDescription = "Notification image"
setPadding(0, dp(4), 0, dp(4))
})
}
}
}
renderExpanded(row.entry.id in expandedIds)
setOnClickListener {
if (!expandedIds.add(row.entry.id)) expandedIds.remove(row.entry.id)
renderExpanded(row.entry.id in expandedIds)
}
setOnLongClickListener {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Delete this log?")
.setMessage("This permanently removes this log and its copied image, if any.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Delete") { _, _ ->
Thread {
EncryptedNotificationLogStore(requireContext().applicationContext).delete(row.entry.id)
activity?.runOnUiThread(::loadLogs)
}.start()
}
.show()
true
}
}
private fun values(entry: NotificationLogEntry, settings: LogViewSettings): List<String> =
settings.order.filter { it in settings.visibleFields }.mapNotNull { fieldValue(it, entry, settings) }
private fun fieldValue(field: LogField, entry: NotificationLogEntry, settings: LogViewSettings): String? = when (field) {
LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings.timestampZone, 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.APP_CANCELLED -> "App cancelled notification"
NotificationAction.APP_CANCELLED_ALL -> "App cancelled all notifications"
NotificationAction.USER_DISMISSED -> "User dismissed notification"
NotificationAction.USER_DISMISSED_ALL -> "User dismissed all notifications"
else -> action.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase)
}
private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String = DateFormat.getDateTimeInstance().apply {
timeZone = when (zone) {
TimestampZone.UTC -> java.util.TimeZone.getTimeZone("UTC")
TimestampZone.LOCAL_NOW -> java.util.TimeZone.getDefault()
TimestampZone.EVENT_LOCAL -> eventTimeZoneId?.let(java.util.TimeZone::getTimeZone) ?: java.util.TimeZone.getDefault()
}
}.format(Date(value))
private fun isListenerEnabled(): Boolean = requireContext().getSystemService(android.app.NotificationManager::class.java)
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
private fun confirmClearLogs() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Clear logs?")
.setMessage("This permanently removes every stored notification log and copied image.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Clear") { _, _ ->
EncryptedNotificationLogStore(requireContext()).clear()
expandedIds.clear()
loadLogs()
}
.show()
}
private fun confirmExport() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Export unencrypted logs?")
.setMessage("The exported file will not be encrypted. Anyone with access to its destination can read the selected log fields.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Continue") { _, _ -> chooseExportFormat() }
.show()
}
private fun chooseExportFormat() {
MaterialAlertDialogBuilder(requireContext())
.setItems(arrayOf("CSV", "Formatted text", "HTML ZIP")) { _, which ->
pendingExport = ExportFormat.entries[which]
createDocument.launch("notification-log.${pendingExport!!.extension}")
}
.show()
}
private fun writeExport(uri: Uri) {
val format = pendingExport ?: return
val context = requireContext().applicationContext
Thread {
val entries = EncryptedNotificationLogStore(context).readAll()
val settings = LogViewSettingsStore(context).load()
context.contentResolver.openOutputStream(uri)?.use { output ->
when (format) {
ExportFormat.CSV -> output.write(LogExporter.csv(entries, settings).encodeToByteArray())
ExportFormat.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray())
ExportFormat.HTML_ZIP -> writeHtmlExport(output, entries, settings, context)
}
}
}.start()
}
private fun writeHtmlExport(output: java.io.OutputStream, entries: List<NotificationLogEntry>, settings: LogViewSettings, context: android.content.Context) {
ZipOutputStream(output).use { zip ->
val imageStore = EncryptedImageStore(context)
val exportedImages = entries.filter { it.imageId != null && LogField.CONTENTS in settings.visibleFields }
.mapNotNull { entry -> imageStore.read(entry.imageId!!)?.let { entry.imageId to it } }
.toMap()
zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html"))
zip.write(LogExporter.html(entries, settings) { entry ->
entry.imageId?.takeIf(exportedImages::containsKey)?.let { "images/$it.png" }
}.encodeToByteArray())
zip.closeEntry()
exportedImages.forEach { (imageId, image) ->
zip.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png"))
zip.write(image)
zip.closeEntry()
}
}
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private data class LogRow(val entry: NotificationLogEntry, val imageBytes: ByteArray?)
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") }
}
@@ -1,164 +0,0 @@
package se.ajpanton.notificationlog.capture
import android.app.Notification
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 java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class NotificationCaptureService : NotificationListenerService() {
private val activeNotifications = mutableMapOf<String, NotificationSnapshot>()
private lateinit var logStore: EncryptedNotificationLogStore
private lateinit var writeExecutor: ExecutorService
private lateinit var ruleStore: LoggingRuleStore
private lateinit var imageStore: EncryptedImageStore
override fun onCreate() {
super.onCreate()
logStore = EncryptedNotificationLogStore(this)
ruleStore = LoggingRuleStore(this)
imageStore = EncryptedImageStore(this)
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "notification-log-writer")
}
}
override fun onListenerConnected() {
super.onListenerConnected()
getActiveNotifications()?.forEach { sbn ->
val snapshot = NotificationContents.snapshot(sbn, this)
SeenApps.markSeen(snapshot.packageName)
activeNotifications[snapshot.key] = snapshot
record(snapshot, NotificationAction.ALREADY_ACTIVE, LoggingType.APPEARING, includeContents = true)
}
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
val snapshot = NotificationContents.snapshot(sbn, this)
SeenApps.markSeen(snapshot.packageName)
val previous = activeNotifications.put(snapshot.key, snapshot)
when {
previous == null -> record(snapshot, NotificationAction.APPEARED, LoggingType.APPEARING, includeContents = true)
NotificationChangeClassifier.isMeaningfulEdit(previous, snapshot) &&
!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates) ->
record(snapshot, NotificationAction.EDITED, LoggingType.EDITS, includeContents = true, previousSnapshot = previous)
}
}
override fun onNotificationRemoved(
sbn: StatusBarNotification,
rankingMap: RankingMap,
reason: Int,
) {
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn, this)
SeenApps.markSeen(snapshot.packageName)
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false)
}
override fun onDestroy() {
writeExecutor.shutdown()
super.onDestroy()
}
private fun record(
snapshot: NotificationSnapshot,
action: NotificationAction,
loggingType: LoggingType,
includeContents: Boolean,
previousSnapshot: NotificationSnapshot? = null,
) {
if (!NotificationRuleEvaluator.allows(ruleStore.ruleFor(loggingType), snapshot.packageName)) return
val appName = appName(snapshot.packageName)
val retainImage = includeContents && snapshot.imageBytes != null &&
NotificationRuleEvaluator.allows(ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName)
val entry = NotificationLogEntry(
recordedAtEpochMillis = System.currentTimeMillis(),
eventTimeZoneId = java.util.TimeZone.getDefault().id,
packageName = snapshot.packageName,
appName = appName,
action = action,
contents = if (includeContents) visibleContents(snapshot) else null,
previousContents = previousSnapshot?.let(::visibleContents),
imageId = if (retainImage) java.util.UUID.randomUUID().toString() else null,
)
writeExecutor.execute {
try {
if (retainImage) {
try {
imageStore.save(entry.imageId!!, snapshot.imageBytes!!)
} catch (error: Exception) {
Log.w(TAG, "Could not retain notification image; keeping the text event", error)
logStore.append(entry.copy(imageId = null))
return@execute
}
}
logStore.append(entry)
} catch (error: Exception) {
entry.imageId?.let(imageStore::delete)
Log.e(TAG, "Could not persist notification log entry", error)
}
}
}
private fun visibleContents(snapshot: NotificationSnapshot): String? {
val text = snapshot.textContents?.takeIf {
NotificationRuleEvaluator.allows(ruleStore.ruleFor(LoggingType.TEXT_CONTENT), snapshot.packageName)
}
val image = snapshot.hasImage && NotificationRuleEvaluator.allows(
ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName,
)
return listOfNotNull(text, if (image) "[image]" else null)
.joinToString("")
.take(MAX_CONTENT_CHARACTERS)
.ifEmpty { null }
}
private fun appName(packageName: String): String = try {
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
packageManager.getApplicationLabel(applicationInfo).toString()
} catch (error: PackageManager.NameNotFoundException) {
Log.w(TAG, "Package disappeared while its notification was being recorded: $packageName", error)
packageName
}
private fun actionForRemoval(reason: Int): NotificationAction = when (reason) {
REASON_APP_CANCEL -> NotificationAction.APP_CANCELLED
REASON_APP_CANCEL_ALL -> NotificationAction.APP_CANCELLED_ALL
REASON_CANCEL -> NotificationAction.USER_DISMISSED
REASON_CANCEL_ALL -> NotificationAction.USER_DISMISSED_ALL
REASON_CLICK -> NotificationAction.USER_CLICKED
REASON_LISTENER_CANCEL -> NotificationAction.LISTENER_DISMISSED
REASON_LISTENER_CANCEL_ALL -> NotificationAction.LISTENER_DISMISSED_ALL
REASON_SNOOZED -> NotificationAction.SNOOZED
REASON_TIMEOUT -> NotificationAction.TIMED_OUT
REASON_CHANNEL_BANNED -> NotificationAction.CHANNEL_BANNED
REASON_CHANNEL_REMOVED -> NotificationAction.CHANNEL_REMOVED
REASON_PACKAGE_BANNED -> NotificationAction.PACKAGE_BANNED
REASON_PACKAGE_CHANGED -> NotificationAction.PACKAGE_CHANGED
REASON_PACKAGE_SUSPENDED -> NotificationAction.PACKAGE_SUSPENDED
REASON_PROFILE_TURNED_OFF -> NotificationAction.PROFILE_TURNED_OFF
REASON_USER_STOPPED -> NotificationAction.USER_STOPPED
REASON_GROUP_SUMMARY_CANCELED -> NotificationAction.GROUP_SUMMARY_CANCELLED
REASON_GROUP_OPTIMIZATION -> NotificationAction.GROUP_OPTIMIZED
REASON_UNAUTOBUNDLED -> NotificationAction.UNAUTOBUNDLED
REASON_CLEAR_DATA -> NotificationAction.CLEAR_DATA
REASON_ASSISTANT_CANCEL -> NotificationAction.ASSISTANT_CANCELLED
REASON_LOCKDOWN -> NotificationAction.LOCKDOWN
REASON_ERROR -> NotificationAction.SYSTEM_ERROR
else -> NotificationAction.OTHER_REMOVAL
}
private companion object {
const val TAG = "NotificationCapture"
const val MAX_CONTENT_CHARACTERS = 16_000
}
}
@@ -1,87 +0,0 @@
package se.ajpanton.notificationlog.capture
import android.app.Notification
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Icon
import android.os.Bundle
import android.service.notification.StatusBarNotification
data class NotificationSnapshot(
val key: String,
val packageName: String,
val textContents: String?,
val hasImage: Boolean,
val isRoutine: Boolean,
val imageBytes: ByteArray?,
)
object NotificationContents {
fun extract(notification: Notification): String? {
val parts = linkedSetOf<String>()
val extras = notification.extras ?: Bundle.EMPTY
extras.getCharSequence(Notification.EXTRA_TITLE)?.addTo(parts)
extras.getCharSequence(Notification.EXTRA_TEXT)?.addTo(parts)
extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.addTo(parts)
extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.addTo(parts)
extras.getCharSequence(Notification.EXTRA_SUMMARY_TEXT)?.addTo(parts)
extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)?.forEach { it?.addTo(parts) }
Notification.MessagingStyle.Message.getMessagesFromBundleArray(
extras.getParcelableArray(Notification.EXTRA_MESSAGES, Bundle::class.java),
).forEach { message ->
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts)
}
return parts.takeIf { it.isNotEmpty() }?.joinToString("")
}
fun snapshot(sbn: StatusBarNotification, context: Context): NotificationSnapshot {
val notification = sbn.notification
val extras = notification.extras
val picture = extras?.getParcelable(Notification.EXTRA_PICTURE, Bitmap::class.java)
val pictureIcon = extras?.getParcelable(Notification.EXTRA_PICTURE_ICON, Icon::class.java)
return NotificationSnapshot(
key = sbn.key,
packageName = sbn.packageName,
textContents = extract(notification),
hasImage = extras?.let {
it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON)
} == true,
isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
extras?.containsKey(Notification.EXTRA_PROGRESS) == true,
imageBytes = picture?.toPng() ?: pictureIcon?.let { icon -> icon.loadDrawable(context)?.toBitmap()?.toPng() },
)
}
private fun Drawable.toBitmap(): Bitmap? = when (this) {
is BitmapDrawable -> bitmap
else -> runCatching {
val sourceWidth = maxOf(1, intrinsicWidth)
val sourceHeight = maxOf(1, intrinsicHeight)
val scale = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(sourceWidth, sourceHeight))
val width = (sourceWidth * scale).toInt()
val height = (sourceHeight * scale).toInt()
Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmap ->
setBounds(0, 0, width, height)
draw(Canvas(bitmap))
}
}.getOrNull()
}
private fun Bitmap.toPng(): ByteArray? = runCatching {
val scale = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(width, height))
val bitmap = if (scale < 1f) Bitmap.createScaledBitmap(this, (width * scale).toInt(), (height * scale).toInt(), true) else this
java.io.ByteArrayOutputStream().use { output ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
output.toByteArray()
}
}.getOrNull()
private fun CharSequence.addTo(parts: MutableSet<String>) {
toString().trim().takeIf { it.isNotEmpty() }?.let(parts::add)
}
private const val MAX_IMAGE_DIMENSION = 1600
}
@@ -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,115 +0,0 @@
package se.ajpanton.notificationlog.data
import android.content.Context
import android.util.AtomicFile
import se.ajpanton.notificationlog.model.NotificationLogEntry
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.FileNotFoundException
import java.io.File
/**
* An atomically replaced encrypted event log. It keeps every persisted log field
* encrypted, including timestamps and package names, while the app is at rest.
*/
class EncryptedNotificationLogStore(context: Context) {
private val lock = Any()
private val file = AtomicFile(context.filesDir.resolve(FILE_NAME))
private val imageDirectory = File(context.filesDir, IMAGE_DIRECTORY_NAME)
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
fun readAll(): List<NotificationLogEntry> = synchronized(lock) {
val payload = readPayloadOrNull() ?: return emptyList()
NotificationLogEntryJson.decode(cipher.decrypt(payload))
}
fun append(entry: NotificationLogEntry) = synchronized(lock) {
val entries = (readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: emptyList())
val retained = retain(entries + entry)
write(retained)
(entries.mapNotNull { it.imageId } - retained.mapNotNull { it.imageId }.toSet()).forEach(::deleteImage)
}
fun replaceAll(entries: List<NotificationLogEntry>) = synchronized(lock) {
write(entries)
}
fun clear() = synchronized(lock) {
file.delete()
imageDirectory.listFiles()?.forEach(File::delete)
}
fun delete(id: String): Boolean = synchronized(lock) {
val entries = readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: return false
val entry = entries.firstOrNull { it.id == id } ?: return false
write(entries.filterNot { it.id == id })
entry.imageId?.let(::deleteImage)
true
}
private fun write(entries: List<NotificationLogEntry>) {
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
val output = file.startWrite()
try {
val stream = DataOutputStream(BufferedOutputStream(output))
stream.writeInt(FILE_VERSION)
stream.writeInt(payload.initializationVector.size)
stream.write(payload.initializationVector)
stream.writeInt(payload.cipherText.size)
stream.write(payload.cipherText)
stream.flush()
file.finishWrite(output)
} catch (error: Exception) {
file.failWrite(output)
throw error
}
}
private fun retain(entries: List<NotificationLogEntry>): List<NotificationLogEntry> {
var retained = entries.takeLast(MAX_ENTRIES)
while (retained.size > 1 && NotificationLogEntryJson.encode(retained).size > MAX_PLAINTEXT_BYTES) {
retained = retained.drop(1)
}
require(NotificationLogEntryJson.encode(retained).size <= MAX_PLAINTEXT_BYTES) {
"A notification event exceeds the encrypted log retention limit."
}
return retained
}
private fun deleteImage(id: String) {
if (id.matches(IMAGE_ID_PATTERN)) File(imageDirectory, "$id.bin").delete()
}
private fun readPayloadOrNull(): EncryptedPayload? {
val input = try {
file.openRead()
} catch (_: FileNotFoundException) {
return null
}
DataInputStream(BufferedInputStream(input)).use { stream ->
require(stream.readInt() == FILE_VERSION) { "Unsupported encrypted notification-log version." }
val initializationVector = readByteArray(stream, MAX_IV_BYTES)
val cipherText = readByteArray(stream, MAX_CIPHER_TEXT_BYTES)
return EncryptedPayload(initializationVector, cipherText)
}
}
private fun readByteArray(stream: DataInputStream, maximumLength: Int): ByteArray {
val length = stream.readInt()
require(length in 1..maximumLength) { "Invalid encrypted notification-log field length." }
return ByteArray(length).also(stream::readFully)
}
private companion object {
const val FILE_NAME = "notification-log.v1"
const val FILE_VERSION = 1
const val MAX_IV_BYTES = 32
const val MAX_CIPHER_TEXT_BYTES = 50 * 1024 * 1024
const val MAX_PLAINTEXT_BYTES = 20 * 1024 * 1024
const val MAX_ENTRIES = 10_000
const val IMAGE_DIRECTORY_NAME = "notification-images"
val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}")
}
}
@@ -1,74 +0,0 @@
package se.ajpanton.notificationlog.export
import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettings
import se.ajpanton.notificationlog.settings.TimestampZone
import java.text.DateFormat
import java.util.Date
import java.util.TimeZone
object LogExporter {
fun csv(entries: List<NotificationLogEntry>, settings: LogViewSettings): String =
rows(entries, settings).joinToString("\n") { row -> row.joinToString(",") { csvValue(it) } }
fun formatted(entries: List<NotificationLogEntry>, settings: LogViewSettings): String {
val rows = rows(entries, settings)
val widths = rows.fold(emptyList<Int>()) { current, row -> row.mapIndexed { i, value -> maxOf(current.getOrElse(i) { 0 }, value.length) } }
return rows.joinToString("\n") { row -> row.mapIndexed { i, value -> value.padEnd(widths[i]) }.joinToString(" ") }
}
fun html(
entries: List<NotificationLogEntry>,
settings: LogViewSettings,
imagePath: (NotificationLogEntry) -> String? = { null },
): String = buildString {
append("<!doctype html><meta charset=\"utf-8\"><table><thead><tr>")
settings.order.filter { it in settings.visibleFields }.forEach { append("<th>${escape(header(it))}</th>") }
append("</tr></thead><tbody>")
entries.sortedByDescending { it.recordedAtEpochMillis }.forEach { entry ->
append("<tr>")
settings.order.filter { it in settings.visibleFields }.forEach { field ->
append("<td>${htmlValue(field, entry, settings, imagePath(entry))}</td>")
}
append("</tr>")
}
append("</tbody></table>")
}
private fun rows(entries: List<NotificationLogEntry>, settings: LogViewSettings) = listOf(
settings.order.filter { it in settings.visibleFields }.map(::header),
) + entries.sortedByDescending { it.recordedAtEpochMillis }.map { entry ->
settings.order.filter { it in settings.visibleFields }.map { field -> when (field) {
LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings.timestampZone, entry.eventTimeZoneId)
LogField.APP_NAME -> entry.appName; LogField.PACKAGE_NAME -> entry.packageName
LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ')
LogField.CONTENTS -> entry.contents ?: ""
} }
}
private fun header(field: LogField) = field.name.lowercase().replace('_', ' ')
private fun htmlValue(field: LogField, entry: NotificationLogEntry, settings: LogViewSettings, imagePath: String?): String {
val value = when (field) {
LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings.timestampZone, entry.eventTimeZoneId)
LogField.APP_NAME -> entry.appName
LogField.PACKAGE_NAME -> entry.packageName
LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ')
LogField.CONTENTS -> entry.contents.orEmpty()
}
val escaped = escape(value)
return if (field == LogField.CONTENTS && imagePath != null) {
escaped.replace("[image]", "<img src=\"${escape(imagePath)}\" alt=\"[image]\">")
} else {
escaped
}
}
private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String = DateFormat.getDateTimeInstance().apply {
timeZone = when (zone) {
TimestampZone.UTC -> TimeZone.getTimeZone("UTC")
TimestampZone.LOCAL_NOW -> TimeZone.getDefault()
TimestampZone.EVENT_LOCAL -> eventTimeZoneId?.let(TimeZone::getTimeZone) ?: TimeZone.getDefault()
}
}.format(Date(value))
private fun csvValue(value: String) = "\"${value.replace("\"", "\"\"")}\""
private fun escape(value: String) = value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
}
@@ -1,26 +0,0 @@
package se.ajpanton.notificationlog.settings
data class ListedApp(val label: String, val packageName: String, val seen: Boolean, val selected: Boolean)
sealed interface AppListItem {
data class App(val value: ListedApp) : AppListItem
data object Separator : AppListItem
}
object AppListOrdering {
fun items(allApps: List<ListedApp>, onlySeen: Boolean, seenAppsFirst: Boolean): List<AppListItem> {
val apps = allApps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label })
val shown = if (onlySeen) apps.filter { it.seen || it.selected } else apps
val seen = shown.filter { it.seen }
val other = shown.filterNot { it.seen }
val separate = (onlySeen || seenAppsFirst) && seen.isNotEmpty() && other.isNotEmpty()
return buildList {
if (separate) {
seen.forEach { add(AppListItem.App(it)) }
add(AppListItem.Separator)
other.forEach { add(AppListItem.App(it)) }
} else {
shown.forEach { add(AppListItem.App(it)) }
}
}
}
}
@@ -1,10 +0,0 @@
package se.ajpanton.notificationlog.settings
enum class LogField { TIMESTAMP, APP_NAME, PACKAGE_NAME, ACTION, CONTENTS }
enum class TimestampZone { LOCAL_NOW, UTC, EVENT_LOCAL }
data class LogViewSettings(
val visibleFields: Set<LogField> = setOf(LogField.TIMESTAMP, LogField.APP_NAME, LogField.ACTION, LogField.CONTENTS),
val order: List<LogField> = LogField.entries,
val timestampZone: TimestampZone = TimestampZone.LOCAL_NOW,
)
@@ -1,61 +0,0 @@
package se.ajpanton.notificationlog.settings
import android.content.Context
import androidx.core.content.edit
/** Persists rule choices only; notification contents never enter SharedPreferences. */
class LoggingRuleStore(context: Context) {
private val appContext = context.applicationContext
private val preferences = appContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
fun ruleFor(type: LoggingType): LoggingRule = LoggingRule(
enabled = preferences.getBoolean(key(type, "enabled"), true),
appRuleMode = AppRuleMode.valueOf(
preferences.getString(key(type, "app_rule_mode"), AppRuleMode.BLACKLIST.name)
?: AppRuleMode.BLACKLIST.name,
),
selectedPackages = preferences.getStringSet(key(type, "selected_packages"), emptySet())?.toSet()
?: emptySet(),
onlySeenApps = preferences.getBoolean(key(type, "only_seen_apps"), false),
seenAppsFirst = preferences.getBoolean(key(type, "seen_apps_first"), true),
ignoreRoutineUpdates = preferences.getBoolean(key(type, "ignore_routine_updates"), true),
)
fun save(type: LoggingType, rule: LoggingRule) {
preferences.edit {
write(type, rule)
}
updateListenerComponent()
}
fun copy(from: LoggingType, targets: Set<LoggingType>) {
val source = ruleFor(from).copyForTarget()
preferences.edit {
targets.filterNot { it == from }.forEach { target ->
write(target, source.copy(selectedPackages = source.selectedPackages.toSet()))
}
}
updateListenerComponent()
}
private fun android.content.SharedPreferences.Editor.write(type: LoggingType, rule: LoggingRule) {
putBoolean(key(type, "enabled"), rule.enabled)
putString(key(type, "app_rule_mode"), rule.appRuleMode.name)
putStringSet(key(type, "selected_packages"), rule.selectedPackages)
putBoolean(key(type, "only_seen_apps"), rule.onlySeenApps)
putBoolean(key(type, "seen_apps_first"), rule.seenAppsFirst)
putBoolean(key(type, "ignore_routine_updates"), rule.ignoreRoutineUpdates)
}
private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix"
private fun updateListenerComponent() {
NotificationListenerComponentController.update(appContext, LoggingType.entries.map(::ruleFor))
}
private companion object {
const val FILE_NAME = "logging-rules"
}
}
internal fun LoggingRule.copyForTarget(): LoggingRule = copy(selectedPackages = selectedPackages.toSet())
@@ -1,37 +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: Collection<LoggingRule>) {
val applicationContext = context.applicationContext
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
val packageManager = applicationContext.packageManager
val desired = if (NotificationListenerPolicy.shouldRun(rules)) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
if (packageManager.getComponentEnabledSetting(component) == desired) return
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
if (desired == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
try {
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,6 +0,0 @@
package se.ajpanton.notificationlog.settings
/** The listener is useful only when at least one event type is enabled. */
object NotificationListenerPolicy {
fun shouldRun(rules: Collection<LoggingRule>): Boolean = rules.any { it.enabled }
}
@@ -1,12 +0,0 @@
package se.ajpanton.notificationlog.settings
object NotificationRuleEvaluator {
fun allows(rule: LoggingRule, packageName: String): Boolean {
if (!rule.enabled) return false
val selected = packageName in rule.selectedPackages
return when (rule.appRuleMode) {
AppRuleMode.WHITELIST -> selected
AppRuleMode.BLACKLIST -> !selected
}
}
}
@@ -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
@@ -0,0 +1,342 @@
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.content.res.ColorStateList
import android.text.SpannableString
import android.text.Spanned
import android.text.style.StyleSpan
import android.text.style.UnderlineSpan
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
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.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
class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
private var binding: FragmentEventSettingsBinding? = null
private lateinit var store: AppFilterSettingsStore
private lateinit var perAppEventSettings: PerAppEventSettingsStore
private lateinit var appAdapter: AppListAdapter
private var appLoadGeneration = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentEventSettingsBinding.bind(view)
store = AppFilterSettingsStore(requireContext())
perAppEventSettings = PerAppEventSettingsStore(requireContext())
appAdapter = AppListAdapter(::setPackageSelected, ::showPerAppEventSettings)
binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
binding!!.appList.adapter = appAdapter
binding!!.appListRefresh.setOnRefreshListener(::reloadAppList)
installBottomRefresh()
bindRule()
}
override fun onDestroyView() {
appLoadGeneration++
binding = null
super.onDestroyView()
}
@Suppress("ClickableViewAccessibility") // RecyclerView has no bottom pull-to-refresh API.
private fun installBottomRefresh() {
val list = binding!!.appList
var touchStartY = 0f
list.setOnTouchListener { _, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> touchStartY = event.y
MotionEvent.ACTION_UP -> if (!list.canScrollVertically(1) && event.y < touchStartY) reloadAppList()
}
false
}
}
private fun bindRule() {
val rule = store.load()
listOf(
binding!!.appRuleToggle,
binding!!.onlySeenToggle,
binding!!.seenFirstToggle,
).forEach { it.setOnCheckedChangeListener(null) }
binding!!.appRuleToggle.isChecked = rule.mode == AppRuleMode.WHITELIST
binding!!.onlySeenToggle.isChecked = rule.onlySeenApps
binding!!.seenFirstToggle.isChecked = rule.seenAppsFirst
updateRuleLabels(rule)
binding!!.appRuleToggle.setOnCheckedChangeListener { _, checked ->
save { copy(mode = if (checked) AppRuleMode.WHITELIST else AppRuleMode.BLACKLIST) }
}
binding!!.onlySeenToggle.setOnCheckedChangeListener { _, checked -> save { copy(onlySeenApps = checked) } }
binding!!.seenFirstToggle.setOnCheckedChangeListener { _, checked -> save { copy(seenAppsFirst = checked) } }
reloadAppList()
}
private fun save(change: AppFilterSettings.() -> AppFilterSettings) {
store.save(store.load().change())
bindRule()
}
private fun updateRuleLabels(rule: AppFilterSettings) {
binding!!.appRuleToggle.text = modeLabel(rule.mode)
binding!!.onlySeenToggle.text = "Show only seen and edited apps"
binding!!.seenFirstToggle.text = "Show seen apps first"
}
private fun modeLabel(mode: AppRuleMode): CharSequence {
val text = "Blacklist / Whitelist"
val selected = if (mode == AppRuleMode.BLACKLIST) "Blacklist" else "Whitelist"
val start = text.indexOf(selected)
return SpannableString(text).apply {
setSpan(StyleSpan(android.graphics.Typeface.BOLD), start, start + selected.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
setSpan(UnderlineSpan(), start, start + selected.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
private fun reloadAppList() {
val currentBinding = binding ?: return
if (currentBinding.appListRefresh.isRefreshing) {
// A fresh swipe replaces the current snapshot; no live updates occur otherwise.
}
val generation = ++appLoadGeneration
currentBinding.appListLoading.visibility = View.VISIBLE
currentBinding.appListRefresh.isRefreshing = true
val rule = store.load()
val seen = SeenApps.snapshot(requireContext())
val context = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
val apps = withContext(Dispatchers.IO) {
context.packageManager.getInstalledApplications(0).map { info ->
ListedApp(
label = context.packageManager.getApplicationLabel(info).toString(),
packageName = info.packageName,
seen = info.packageName in seen,
selected = info.packageName in rule.selectedPackages,
hasEventOverride = perAppEventSettings.hasOverride(info.packageName),
)
}
}
val items = AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst)
if (binding === currentBinding && generation == appLoadGeneration) {
appAdapter.submit(items)
currentBinding.appListLoading.visibility = View.GONE
currentBinding.appListRefresh.isRefreshing = false
}
}
}
private fun setPackageSelected(packageName: String, selected: Boolean) {
val current = store.load()
val packages = current.selectedPackages.toMutableSet().apply {
if (selected) add(packageName) else remove(packageName)
}
store.save(current.copy(selectedPackages = packages))
// Do not reload: an unchecked unseen row remains visible until the next requested refresh.
}
private fun showPerAppEventSettings(app: ListedApp) {
val eventTypes = listOf(LoggingType.APPEARING, LoggingType.DISAPPEARING, LoggingType.EDITS)
val rules = LoggingRuleStore(requireContext())
val content = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(24), 0, dp(24), 0)
}
val useGlobal = SwitchMaterial(requireContext()).apply { text = "Use global" }
val eventToggles = eventTypes.associateWith { type ->
SwitchMaterial(requireContext()).apply {
text = type.label()
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
).apply { marginStart = dp(24) }
}.also(content::addView)
}
content.addView(useGlobal, 0)
fun globalEvents() = eventTypes.associateWith { rules.ruleFor(it).enabled }
fun refresh() {
val useGlobalSetting = perAppEventSettings.usesGlobal(app.packageName)
useGlobal.setOnCheckedChangeListener(null)
useGlobal.isChecked = useGlobalSetting
eventToggles.forEach { (type, toggle) ->
toggle.setOnCheckedChangeListener(null)
toggle.isEnabled = !useGlobalSetting
toggle.isChecked = perAppEventSettings.isEnabled(
app.packageName,
type,
globalEvents().getValue(type),
)
toggle.setOnCheckedChangeListener { _, enabled ->
perAppEventSettings.setEnabled(app.packageName, type, enabled)
}
}
useGlobal.setOnCheckedChangeListener { _, enabled ->
if (enabled) perAppEventSettings.useGlobal(app.packageName)
else perAppEventSettings.startUsingOverride(app.packageName, globalEvents())
appAdapter.updateEventOverride(app.packageName, !enabled)
refresh()
}
}
refresh()
MaterialAlertDialogBuilder(requireContext())
.setTitle("When logging, only log")
.setView(content)
.setPositiveButton("Close", null)
.show()
}
private fun LoggingType.label() = when (this) {
LoggingType.APPEARING -> "Appearing"
LoggingType.DISAPPEARING -> "Disappearing"
LoggingType.EDITS -> "Edits"
else -> error("Only event types are shown in the per-app dialog")
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private class AppListAdapter(
private val onSelectedChanged: (String, Boolean) -> Unit,
private val onEventFilterClicked: (ListedApp) -> Unit,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var items: List<AppListItem> = emptyList()
fun submit(newItems: List<AppListItem>) {
items = newItems
notifyDataSetChanged()
}
override fun getItemViewType(position: Int): Int = when (items[position]) {
is AppListItem.App -> APP
is AppListItem.SectionTitle -> SECTION_TITLE
AppListItem.Separator -> SEPARATOR
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = if (viewType == APP) {
val row = LinearLayout(parent.context).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, dp(parent, 4), 0, dp(parent, 4))
}
val checkbox = CheckBox(parent.context)
val filter = ImageButton(parent.context).apply {
setImageResource(R.drawable.ic_filter_list)
contentDescription = "Edit logging events"
layoutParams = LinearLayout.LayoutParams(dp(parent, 48), dp(parent, 48))
setPadding(dp(parent, 12), dp(parent, 12), dp(parent, 12), dp(parent, 12))
}
val textColumn = LinearLayout(parent.context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
val label = TextView(parent.context).apply { textSize = 16f; setTypeface(typeface, android.graphics.Typeface.BOLD) }
val packageName = TextView(parent.context).apply { textSize = 12f }
textColumn.addView(label)
textColumn.addView(packageName)
row.addView(checkbox)
row.addView(filter)
row.addView(textColumn)
AppHolder(row, checkbox, filter, label, packageName)
} else if (viewType == SECTION_TITLE) {
SectionTitleHolder(TextView(parent.context).apply {
layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.WRAP_CONTENT,
).apply { setMargins(0, dp(parent, 8), 0, dp(parent, 4)) }
setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 12f)
setTypeface(typeface, android.graphics.Typeface.BOLD)
})
} else {
val divider = View(parent.context).apply {
layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, dp(parent, 1)).apply {
setMargins(0, dp(parent, 12), 0, dp(parent, 12))
}
setBackgroundColor(ContextCompat.getColor(parent.context, android.R.color.darker_gray))
}
SeparatorHolder(divider)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = items[position]
if (holder is AppHolder && item is AppListItem.App) {
val app = item.value
holder.label.text = app.label
holder.packageName.text = app.packageName
holder.checkbox.setOnCheckedChangeListener(null)
holder.checkbox.isChecked = app.selected
holder.checkbox.setOnCheckedChangeListener { _, checked ->
onSelectedChanged(app.packageName, checked)
items = items.map { current ->
if (current is AppListItem.App && current.value.packageName == app.packageName) {
AppListItem.App(current.value.copy(selected = checked))
} else current
}
}
holder.filter.contentDescription = "Edit logging events for ${app.label}"
val colors = if (app.hasEventOverride) {
R.color.app_filter_override_background to R.color.app_filter_override_icon
} else {
R.color.app_filter_inherited_background to R.color.app_filter_inherited_icon
}
holder.filter.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(holder.filter.context, colors.first))
holder.filter.imageTintList = ColorStateList.valueOf(ContextCompat.getColor(holder.filter.context, colors.second))
holder.filter.setOnClickListener { onEventFilterClicked(app) }
}
if (holder is SectionTitleHolder && item is AppListItem.SectionTitle) {
holder.text.text = item.value
}
}
override fun getItemCount(): Int = items.size
fun updateEventOverride(packageName: String, hasEventOverride: Boolean) {
val index = items.indexOfFirst { it is AppListItem.App && it.value.packageName == packageName }
if (index < 0) return
val app = (items[index] as AppListItem.App).value
items = items.toMutableList().also { it[index] = AppListItem.App(app.copy(hasEventOverride = hasEventOverride)) }
notifyItemChanged(index)
}
private class AppHolder(
view: View,
val checkbox: CheckBox,
val filter: ImageButton,
val label: TextView,
val packageName: TextView,
) : RecyclerView.ViewHolder(view)
private class SeparatorHolder(view: View) : RecyclerView.ViewHolder(view)
private class SectionTitleHolder(val text: TextView) : RecyclerView.ViewHolder(text)
private companion object {
const val APP = 0
const val SEPARATOR = 1
const val SECTION_TITLE = 2
fun dp(parent: ViewGroup, value: Int): Int = (value * parent.resources.displayMetrics.density).toInt()
}
}
companion object {
fun newInstance() = FilterAppsFragment()
}
}
@@ -0,0 +1,50 @@
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationsmaster.databinding.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
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentFilterLoggingBinding.bind(view)
val rules = LoggingRuleStore(requireContext())
addToggles(binding!!.eventToggles, listOf(LoggingType.APPEARING, LoggingType.DISAPPEARING, LoggingType.EDITS), rules)
addToggles(binding!!.contentToggles, listOf(LoggingType.TEXT_CONTENT, LoggingType.IMAGE_CONTENT), rules)
val capture = CaptureSettingsStore(requireContext())
binding!!.groupSummaries.isChecked = capture.logGroupSummaries
binding!!.groupSummaries.setOnCheckedChangeListener { _, checked -> capture.logGroupSummaries = checked }
binding!!.routineUpdates.isChecked = rules.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates
binding!!.routineUpdates.setOnCheckedChangeListener { _, checked -> rules.save(LoggingType.EDITS, rules.ruleFor(LoggingType.EDITS).copy(ignoreRoutineUpdates = checked)) }
}
private fun addToggles(
container: android.widget.LinearLayout,
types: List<LoggingType>,
rules: LoggingRuleStore,
) = types.forEach { type ->
container.addView(SwitchMaterial(requireContext()).apply {
text = type.label()
isChecked = rules.ruleFor(type).enabled
setOnCheckedChangeListener { _, checked ->
rules.save(type, rules.ruleFor(type).copy(enabled = checked))
}
})
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun LoggingType.label() = when (this) {
LoggingType.APPEARING -> "Appearing"
LoggingType.DISAPPEARING -> "Disappearing"
LoggingType.EDITS -> "Edits"
LoggingType.TEXT_CONTENT -> "Text content"
LoggingType.IMAGE_CONTENT -> "Image content"
}
}
@@ -0,0 +1,125 @@
package se.ajpanton.notificationsmaster
import android.app.Dialog
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.os.bundleOf
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Full-screen image viewer kept within MainActivity so opening it does not re-lock the app. */
class ImageViewerDialogFragment : DialogFragment() {
private lateinit var root: FrameLayout
private lateinit var image: ZoomImageView
private lateinit var progress: ProgressBar
private var controlsVisible = false
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
root = FrameLayout(requireContext()).apply { setBackgroundColor(Color.BLACK) }
image = ZoomImageView(requireContext()).apply {
visibility = View.GONE
onSingleTap = ::toggleSystemControls
}
progress = ProgressBar(requireContext()).apply { isIndeterminate = true }
root.addView(image, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
root.addView(progress, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
return Dialog(requireContext()).apply {
requestWindowFeature(Window.FEATURE_NO_TITLE)
setContentView(root)
}
}
override fun onStart() {
super.onStart()
dialog?.window?.apply {
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
WindowCompat.setDecorFitsSystemWindows(this, false)
attributes = attributes.apply {
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
hideSystemControls()
loadImage(requireArguments().getString(ARGUMENT_IMAGE_ID)!!)
}
private fun loadImage(imageId: String) {
val targetWidth = resources.displayMetrics.widthPixels
val targetHeight = resources.displayMetrics.heightPixels
val appContext = requireContext().applicationContext
lifecycleScope.launch {
val bitmap = withContext(Dispatchers.IO) { runCatching {
EncryptedImageStore(appContext).read(imageId)?.let { bytes ->
decodeForViewer(bytes, targetWidth, targetHeight)
}
}.getOrNull() }
if (!isAdded) return@launch
progress.visibility = View.GONE
if (bitmap == null) {
root.addView(TextView(requireContext()).apply {
text = "[image]"
setTextColor(Color.WHITE)
textSize = 18f
}, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
} else {
image.setImageBitmap(bitmap)
image.visibility = View.VISIBLE
}
}
}
private fun decodeForViewer(bytes: ByteArray, targetWidth: Int, targetHeight: Int): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
var sampleSize = 1
while (bounds.outWidth / sampleSize > targetWidth * MAX_SCREEN_SCALE ||
bounds.outHeight / sampleSize > targetHeight * MAX_SCREEN_SCALE
) sampleSize *= 2
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sampleSize })
}
private fun toggleSystemControls() {
if (controlsVisible) hideSystemControls() else showSystemControls()
}
private fun hideSystemControls() {
val window = dialog?.window ?: return
WindowInsetsControllerCompat(window, root).apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
}
controlsVisible = false
}
private fun showSystemControls() {
val window = dialog?.window ?: return
WindowInsetsControllerCompat(window, root).show(WindowInsetsCompat.Type.systemBars())
controlsVisible = true
}
companion object {
private const val ARGUMENT_IMAGE_ID = "image_id"
private const val MAX_SCREEN_SCALE = 4
fun newInstance(imageId: String) = ImageViewerDialogFragment().apply {
arguments = bundleOf(ARGUMENT_IMAGE_ID to imageId)
}
}
}
@@ -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)
}
@@ -0,0 +1,159 @@
package se.ajpanton.notificationsmaster
import android.content.ClipData
import android.os.Bundle
import android.view.View
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
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
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentLogDisplayBinding.bind(view)
val store = LogViewSettingsStore(requireContext())
var settings = store.load()
DisplayEvent.entries.forEach { event ->
binding!!.eventToggles.addView(SwitchMaterial(requireContext()).apply {
text = event.name.lowercase().replaceFirstChar(Char::uppercase)
isChecked = event in settings.visibleEvents
setOnCheckedChangeListener { _, checked ->
settings = settings.copy(
visibleEvents = settings.visibleEvents.toMutableSet().apply {
if (checked) add(event) else remove(event)
},
)
store.save(settings)
}
})
}
settings.order.forEach { field ->
val fieldGroup = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
tag = field
}
val toggle = SwitchMaterial(requireContext()).apply {
text = field.label()
isChecked = field in settings.visibleFields
setOnLongClickListener {
startDragAndDrop(
ClipData.newPlainText("field", field.name),
View.DragShadowBuilder(fieldGroup),
fieldGroup,
0,
)
true
}
setOnCheckedChangeListener { _, checked ->
settings = settings.copy(
visibleFields = settings.visibleFields.toMutableSet().apply {
if (checked) add(field) else remove(field)
},
)
store.save(settings)
}
}
fieldGroup.addView(toggle)
if (field == LogField.TIMESTAMP) {
val controls = timestampControls({ settings }) { updated ->
settings = updated
store.save(settings)
}
fieldGroup.addView(controls)
setEnabledRecursively(controls, toggle.isChecked)
toggle.setOnCheckedChangeListener { _, checked ->
settings = settings.copy(
visibleFields = settings.visibleFields.toMutableSet().apply {
if (checked) add(field) else remove(field)
},
)
store.save(settings)
setEnabledRecursively(controls, checked)
}
}
binding!!.fieldToggles.addView(fieldGroup)
}
binding!!.fieldToggles.setOnDragListener { _, event ->
if (event.action != android.view.DragEvent.ACTION_DROP) return@setOnDragListener true
val source = event.localState as? View ?: return@setOnDragListener false
val box = binding!!.fieldToggles
val index = (0 until box.childCount).firstOrNull { event.y < box.getChildAt(it).bottom } ?: box.childCount
box.removeView(source)
box.addView(source, index.coerceAtMost(box.childCount))
settings = settings.copy(order = (0 until box.childCount).map { box.getChildAt(it).tag as LogField })
store.save(settings)
true
}
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun timestampControls(
currentSettings: () -> LogViewSettings,
saveSettings: (LogViewSettings) -> Unit,
): LinearLayout = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(TIMESTAMP_CONTROLS_INDENT_DP), 0, 0, 0)
addDropdown("Timestamp timezone", listOf("Local timezone when event happened", "Current local timezone", "UTC"), TimestampZone.entries.indexOf(currentSettings().timestampZone)) { position ->
val updated = currentSettings().copy(timestampZone = TimestampZone.entries[position])
saveSettings(updated)
}
addDropdown("Date format", listOf("System default", "YYYY-MM-DD", "DD-MM-YYYY", "MM-DD-YYYY"), TimestampDateFormat.entries.indexOf(currentSettings().timestampDateFormat)) { position ->
val updated = currentSettings().copy(timestampDateFormat = TimestampDateFormat.entries[position])
saveSettings(updated)
}
addDropdown("Clock format", listOf("System default", "24-hour clock", "12-hour clock"), TimestampClockFormat.entries.indexOf(currentSettings().timestampClockFormat)) { position ->
val updated = currentSettings().copy(timestampClockFormat = TimestampClockFormat.entries[position])
saveSettings(updated)
}
}
private fun LinearLayout.addDropdown(label: String, values: List<String>, selected: Int, onSelected: (Int) -> Unit) {
addView(TextView(context).apply { text = label })
addView(Spinner(context, Spinner.MODE_DROPDOWN).apply {
setPopupBackgroundDrawable(context.getDrawable(R.drawable.dropdown_popup_background))
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, values)
setSelection(selected)
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) = onSelected(position)
}
})
}
private fun setEnabledRecursively(view: View, enabled: Boolean) {
view.isEnabled = enabled
view.alpha = if (enabled) 1f else DISABLED_TIMESTAMP_CONTROLS_ALPHA
if (view is android.view.ViewGroup) {
(0 until view.childCount).forEach { setEnabledRecursively(view.getChildAt(it), enabled) }
}
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private companion object {
const val TIMESTAMP_CONTROLS_INDENT_DP = 24
const val DISABLED_TIMESTAMP_CONTROLS_ALPHA = 0.5f
}
private fun LogField.label() = when (this) {
LogField.TIMESTAMP -> "Timestamp"
LogField.APP_NAME -> "App name"
LogField.PACKAGE_NAME -> "Package name"
LogField.ACTION -> "What the app did"
LogField.CONTENTS -> "Contents of the notification"
}
}
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog package se.ajpanton.notificationsmaster
import android.os.Bundle import android.os.Bundle
import android.hardware.biometrics.BiometricPrompt import android.hardware.biometrics.BiometricPrompt
@@ -8,6 +8,8 @@ import android.content.res.Configuration
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.WindowManager
import android.widget.RelativeLayout
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@@ -17,24 +19,28 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.forEach
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.commit import androidx.fragment.app.commit
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import se.ajpanton.notificationlog.databinding.ActivityMainBinding import se.ajpanton.notificationsmaster.databinding.ActivityMainBinding
import se.ajpanton.notificationlog.settings.LoggingType import se.ajpanton.notificationsmaster.settings.AppLockStore
import se.ajpanton.notificationlog.settings.AppLockStore import se.ajpanton.notificationsmaster.settings.LogAppFilterStore
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private lateinit var drawerToggle: ActionBarDrawerToggle private lateinit var drawerToggle: ActionBarDrawerToggle
private var permanentSidebar = false private var permanentSidebar = false
private var needsUnlock = true private var needsUnlock = true
private var unlockInProgress = false
private lateinit var appLockStore: AppLockStore
private var currentItemId = R.id.nav_view_logs private var currentItemId = R.id.nav_view_logs
private var drawerNavigationBasePaddingLeft: Int? = null private var drawerNavigationBasePaddingLeft: Int? = null
private var permanentNavigationBasePaddingLeft: Int? = null private var permanentNavigationBasePaddingLeft: Int? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
appLockStore = AppLockStore(this)
// Keep every app surface below the system bars rather than relying on edge-to-edge drawing. // Keep every app surface below the system bars rather than relying on edge-to-edge drawing.
WindowCompat.setDecorFitsSystemWindows(window, true) WindowCompat.setDecorFitsSystemWindows(window, true)
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
@@ -72,7 +78,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
}) })
currentItemId = savedInstanceState?.getInt(STATE_CURRENT_ITEM_ID) ?: R.id.nav_view_logs currentItemId = savedInstanceState?.getInt(STATE_CURRENT_ITEM_ID) ?: R.id.nav_view_logs
navigateTo(currentItemId) if (savedInstanceState == null) {
navigateTo(currentItemId)
} else {
val page = Page.fromMenuItem(currentItemId)
refreshLogProtection(requestUnlock = hasWindowFocus())
syncDrawerSelection(currentItemId)
title = getString(page.titleRes)
}
applyNavigationDrawerInsets() applyNavigationDrawerInsets()
syncDrawerToggle() syncDrawerToggle()
} }
@@ -84,29 +97,60 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (needsUnlock && AppLockStore(this).enabled) requestUnlock() refreshLogProtection(requestUnlock = true)
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
if (!isChangingConfigurations) needsUnlock = true if (!isChangingConfigurations && isProtectedLogPage()) {
needsUnlock = true
showLockedOverlay(true)
}
} }
private fun requestUnlock() { private fun requestUnlock() {
if (!isProtectedLogPage() || !needsUnlock || unlockInProgress) return
unlockInProgress = true
BiometricPrompt.Builder(this) BiometricPrompt.Builder(this)
.setTitle("Unlock Notification Log") .setTitle("Unlock notification logs")
.setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL) .setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL)
.build() .build()
.authenticate(CancellationSignal(), mainExecutor, object : BiometricPrompt.AuthenticationCallback() { .authenticate(CancellationSignal(), mainExecutor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) { needsUnlock = false } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) { finishAndRemoveTask() } needsUnlock = false
unlockInProgress = false
refreshLogProtection()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) {
unlockInProgress = false
if (isProtectedLogPage()) navigateTo(R.id.nav_alerts)
}
}) })
} }
private fun showLockedOverlay(visible: Boolean) {
binding.lockOverlay.visibility = if (visible) View.VISIBLE else View.GONE
}
/** Called immediately when the Settings switch changes, before a task snapshot can be captured. */
fun setAppLockEnabled(enabled: Boolean) {
appLockStore.enabled = enabled
needsUnlock = enabled
refreshLogProtection(requestUnlock = true)
}
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 { override fun onNavigationItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.nav_settings_header) {
return false
}
navigateTo(item.itemId) navigateTo(item.itemId)
if (!permanentSidebar) { if (!permanentSidebar) {
binding.drawerLayout.closeDrawer(GravityCompat.START) binding.drawerLayout.closeDrawer(GravityCompat.START)
@@ -116,21 +160,34 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private fun navigateTo(itemId: Int) { private fun navigateTo(itemId: Int) {
currentItemId = itemId currentItemId = itemId
refreshLogProtection(requestUnlock = hasWindowFocus())
syncDrawerSelection(itemId) syncDrawerSelection(itemId)
val page = Page.fromMenuItem(itemId) val page = Page.fromMenuItem(itemId)
supportFragmentManager.popBackStackImmediate(
null,
androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE,
)
supportFragmentManager.commit { supportFragmentManager.commit {
replace( replace(
R.id.content_frame, R.id.content_frame,
when { when (page) {
page == Page.VIEW_LOGS -> ViewLogsFragment() Page.VIEW_LOGS -> ViewLogsFragment()
page == Page.SETTINGS -> SettingsFragment() Page.SETTINGS -> SettingsFragment()
page.loggingType != null -> EventSettingsFragment.newInstance(page.titleRes, page.loggingType) Page.ALERTS -> AlertsFragment()
else -> PageFragment.newInstance(page.titleRes) 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) title = getString(page.titleRes)
invalidateOptionsMenu()
} }
private fun setupNavigationView(navigationView: NavigationView) { private fun setupNavigationView(navigationView: NavigationView) {
@@ -147,14 +204,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private fun syncNavigationSelection(navigationView: NavigationView, itemId: Int) { private fun syncNavigationSelection(navigationView: NavigationView, itemId: Int) {
val menu = navigationView.menu ?: return val menu = navigationView.menu ?: return
for (index in 0 until menu.size()) { menu.forEach { item ->
val item = menu.getItem(index) item.isCheckable = true
if (item.itemId == R.id.nav_settings_header) { item.isChecked = item.itemId == itemId
item.isCheckable = false
} else {
item.isCheckable = true
item.isChecked = item.itemId == itemId
}
} }
navigationView.invalidate() navigationView.invalidate()
} }
@@ -241,9 +293,16 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.START) binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.START)
binding.navViewDrawer.visibility = View.VISIBLE 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.navViewDrawer)
updateNavigationHeaderHeight(binding.navViewPermanent) updateNavigationHeaderHeight(binding.navViewPermanent)
applyPageTitleVisibility()
} }
private fun updateNavigationHeaderHeight(navigationView: NavigationView) { private fun updateNavigationHeaderHeight(navigationView: NavigationView) {
@@ -263,11 +322,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
} }
} }
private fun countVisibleMenuRows(menu: Menu): Int = (0 until menu.size()).count { menu.getItem(it).isVisible } private fun countVisibleMenuRows(menu: Menu): Int {
var visible = 0
private fun applyPageTitleVisibility() { menu.forEach { if (it.isVisible) visible++ }
(supportFragmentManager.findFragmentById(R.id.content_frame) as? PageFragment) return visible
?.setPageTitleVisible(permanentSidebar)
} }
private fun syncDrawerToggle() { private fun syncDrawerToggle() {
@@ -275,18 +333,54 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
drawerToggle.drawerArrowDrawable.color = getColor(R.color.on_primary) drawerToggle.drawerArrowDrawable.color = getColor(R.color.on_primary)
} }
fun updateLogFilterAction() {
if (currentItemId == R.id.nav_view_logs) invalidateOptionsMenu()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
return updateToolbarActions(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
menu.clear()
return updateToolbarActions(menu)
}
private fun updateToolbarActions(menu: Menu): Boolean {
if (currentItemId != R.id.nav_view_logs) return false
val active = LogAppFilterStore(this).selectedPackages().isNotEmpty()
menu.add(Menu.NONE, MENU_LOG_FILTER, Menu.NONE, "Filter logs by app").apply {
setIcon(if (active) R.drawable.ic_log_filter_active else R.drawable.ic_log_filter)
icon?.setTint(getColor(R.color.on_primary))
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
}
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == MENU_LOG_FILTER) {
(supportFragmentManager.findFragmentById(R.id.content_frame) as? ViewLogsFragment)?.showAppFilter()
return true
}
return super.onOptionsItemSelected(item)
}
private enum class Page( private enum class Page(
val menuId: Int, val menuId: Int,
val titleRes: Int, val titleRes: Int,
val loggingType: LoggingType? = null,
) { ) {
VIEW_LOGS(R.id.nav_view_logs, R.string.page_view_logs), VIEW_LOGS(R.id.nav_view_logs, R.string.page_view_logs),
SETTINGS(R.id.nav_settings, R.string.page_settings), SETTINGS(R.id.nav_settings, R.string.page_settings),
APPEARING(R.id.nav_appearing, R.string.page_appearing, LoggingType.APPEARING), ALERTS(R.id.nav_alerts, R.string.page_alerts),
DISAPPEARING(R.id.nav_disappearing, R.string.page_disappearing, LoggingType.DISAPPEARING), PROFILES(R.id.nav_profiles, R.string.page_profiles),
TEXT_CONTENT(R.id.nav_text_content, R.string.page_text_content, LoggingType.TEXT_CONTENT), RULES(R.id.nav_rules, R.string.page_rules),
IMAGE_CONTENT(R.id.nav_image_content, R.string.page_image_content, LoggingType.IMAGE_CONTENT), APPS(R.id.nav_apps, R.string.page_apps),
EDITS(R.id.nav_edits, R.string.page_edits, LoggingType.EDITS), 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),
; ;
companion object { companion object {
@@ -296,6 +390,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private companion object { private companion object {
const val STATE_CURRENT_ITEM_ID = "current_item_id" const val STATE_CURRENT_ITEM_ID = "current_item_id"
const val MENU_LOG_FILTER = 1
const val PERMANENT_SIDEBAR_FOLDED_WIDTH_MULTIPLIER = 1.1f const val PERMANENT_SIDEBAR_FOLDED_WIDTH_MULTIPLIER = 1.1f
} }
} }
@@ -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()
}
}
@@ -0,0 +1,38 @@
package se.ajpanton.notificationsmaster
import android.app.Application
import android.content.pm.PackageManager
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 NotificationsMasterApplication : Application() {
override fun onCreate() {
super.onCreate()
synchronizeListenerComponent()
VisibilityPolicySync.publishStored(this)
Thread(::removeStaleAppStorage, "notifications-master-storage-cleanup").start()
}
/**
* Clearing app data resets rule preferences but deliberately does not reset
* PackageManager's enabled/disabled state for the listener component.
* Reconcile it before the UI starts so fresh default rules can bind again.
*/
private fun synchronizeListenerComponent() {
NotificationListenerComponentController.synchronize(this)
}
private fun removeStaleAppStorage() {
val installedPackages = packageManager.getInstalledApplications(
PackageManager.ApplicationInfoFlags.of(0),
).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,8 +1,9 @@
package se.ajpanton.notificationlog package se.ajpanton.notificationsmaster
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.util.AttributeSet import android.util.AttributeSet
import androidx.core.graphics.withClip
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
/** /**
@@ -33,9 +34,8 @@ class SafeInsetDrawerLayout @JvmOverloads constructor(
super.dispatchDraw(canvas) super.dispatchDraw(canvas)
return return
} }
val saveCount = canvas.save() canvas.withClip(safeInsetLeft, 0, (width - safeInsetRight).coerceAtLeast(safeInsetLeft), height) {
canvas.clipRect(safeInsetLeft, 0, (width - safeInsetRight).coerceAtLeast(safeInsetLeft), height) super.dispatchDraw(this)
super.dispatchDraw(canvas) }
canvas.restoreToCount(saveCount)
} }
} }
@@ -0,0 +1,217 @@
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.net.Uri
import android.content.ComponentName
import android.content.Intent
import android.provider.Settings
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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
import kotlinx.coroutines.withContext
class SettingsFragment : Fragment(R.layout.fragment_settings) {
private var binding: FragmentSettingsBinding? = null
private var pendingExport: ExportFormat? = null
private val createDocument = registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
uri?.let(::writeExport)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentSettingsBinding.bind(view)
binding!!.exportLogs.setOnClickListener { confirmExport() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
val lockStore = AppLockStore(requireContext())
binding!!.appLock.isChecked = lockStore.enabled
binding!!.appLock.setOnCheckedChangeListener { _, enabled ->
(activity as? MainActivity)?.setAppLockEnabled(enabled) ?: run { lockStore.enabled = enabled }
}
bindStorageLimits()
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
override fun onResume() {
super.onResume()
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access"
}
private fun isListenerEnabled(): Boolean = requireContext().getSystemService(android.app.NotificationManager::class.java)
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
private fun bindStorageLimits() {
val store = StorageLimitsStore(requireContext())
val limits = store.load()
binding!!.logLimitMib.setText((limits.logBytes / StorageLimitsStore.MEBIBYTE).toString())
binding!!.imageLimitMib.setText((limits.imageBytes / StorageLimitsStore.MEBIBYTE).toString())
binding!!.saveStorageLimits.setOnClickListener {
val logLimit = binding!!.logLimitMib.text.toString().toLongOrNull()
val imageLimit = binding!!.imageLimitMib.text.toString().toLongOrNull()
if (logLimit == null || imageLimit == null ||
logLimit !in 1..MAXIMUM_LIMIT_MIB || imageLimit !in 1..MAXIMUM_LIMIT_MIB
) {
binding!!.logLimitMib.error = "Enter 1 to $MAXIMUM_LIMIT_MIB MiB"
binding!!.imageLimitMib.error = "Enter 1 to $MAXIMUM_LIMIT_MIB MiB"
return@setOnClickListener
}
store.save(StorageLimits(logLimit * StorageLimitsStore.MEBIBYTE, imageLimit * StorageLimitsStore.MEBIBYTE))
val appContext = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
EncryptedNotificationLogStore(appContext).enforceLimits()
}
}
}
private fun confirmClearLogs() {
val filterStore = LogAppFilterStore(requireContext())
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle("Clear logs?")
.setMessage("This permanently removes every stored notification log and copied image.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Clear") { _, _ ->
clearLogs()
}
if (filterStore.selectedPackages().isNotEmpty()) {
dialog.setNeutralButton("Clear and reset filter") { _, _ ->
filterStore.clear()
(activity as? MainActivity)?.updateLogFilterAction()
clearLogs()
}
}
dialog.show()
}
private fun clearLogs() {
val appContext = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
EncryptedNotificationLogStore(appContext).clear()
}
}
private fun chooseExportFormat() {
MaterialAlertDialogBuilder(requireContext())
.setItems(arrayOf("CSV (no images)", "Formatted text (no images)", "HTML ZIP")) { _, which ->
pendingExport = ExportFormat.entries[which]
createDocument.launch("notifications-master.${pendingExport!!.extension}")
}
.show()
}
private fun confirmExport() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Export unencrypted logs?")
.setMessage("The exported file will not be encrypted. Anyone with access to its destination can read the selected log fields.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Continue") { _, _ -> chooseExportFormat() }
.show()
}
private fun writeExport(uri: Uri) {
val format = pendingExport ?: return
val context = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
val result = runCatching {
withContext(Dispatchers.IO) {
val logStore = EncryptedNotificationLogStore(context)
val settings = LogViewSettingsStore(context).load()
context.contentResolver.openOutputStream(uri)?.use { output ->
when (format) {
ExportFormat.CSV -> writeCsv(output.bufferedWriter(Charsets.UTF_8), logStore, settings, context)
ExportFormat.FORMATTED -> writeFormatted(output.bufferedWriter(Charsets.UTF_8), logStore, settings, context)
ExportFormat.HTML_ZIP -> writeHtmlZip(ZipOutputStream(output), logStore, settings, context)
}
}
}
}
pendingExport = null
result.exceptionOrNull()?.let { error ->
android.widget.Toast.makeText(requireContext(), "Export failed: ${error.message ?: "unknown error"}", android.widget.Toast.LENGTH_LONG).show()
}
}
}
private fun writeCsv(
writer: java.io.BufferedWriter,
logStore: EncryptedNotificationLogStore,
settings: se.ajpanton.notificationsmaster.settings.LogViewSettings,
context: android.content.Context,
) = writer.use {
it.write(LogExporter.csvHeader(settings))
logStore.forEachNewest { entry ->
it.newLine()
it.write(LogExporter.csvRow(entry, settings, context))
}
}
private fun writeFormatted(
writer: java.io.BufferedWriter,
logStore: EncryptedNotificationLogStore,
settings: se.ajpanton.notificationsmaster.settings.LogViewSettings,
context: android.content.Context,
) = writer.use {
val headers = LogExporter.headers(settings)
val widths = headers.map { header -> header.length }.toMutableList()
logStore.forEachNewest { entry ->
LogExporter.values(entry, settings, context).forEachIndexed { index, value ->
widths[index] = maxOf(widths[index], value.length)
}
}
it.write(LogExporter.formattedRow(headers, widths))
logStore.forEachNewest { entry ->
it.newLine()
it.write(LogExporter.formattedRow(LogExporter.values(entry, settings, context), widths))
}
}
private fun writeHtmlZip(
zip: ZipOutputStream,
logStore: EncryptedNotificationLogStore,
settings: se.ajpanton.notificationsmaster.settings.LogViewSettings,
context: android.content.Context,
) = zip.use { zipOutput ->
val imageStore = EncryptedImageStore(context)
zipOutput.putNextEntry(java.util.zip.ZipEntry("notifications-master.html"))
zipOutput.write(LogExporter.htmlStart(settings).encodeToByteArray())
logStore.forEachNewest { entry ->
val imagePath = entry.imageId
?.takeIf { LogField.CONTENTS in settings.visibleFields && imageStore.exists(it) }
?.let { imageId -> "images/$imageId.png" }
zipOutput.write(LogExporter.htmlRow(entry, settings, imagePath, context).encodeToByteArray())
}
zipOutput.write(LogExporter.htmlEnd().encodeToByteArray())
zipOutput.closeEntry()
if (LogField.CONTENTS in settings.visibleFields) {
logStore.forEachNewest { entry ->
entry.imageId?.let { imageId ->
imageStore.read(imageId)?.let { image ->
zipOutput.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png"))
zipOutput.write(image)
zipOutput.closeEntry()
}
}
}
}
}
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") }
private companion object {
const val MAXIMUM_LIMIT_MIB = 1024L * 1024L
}
}
@@ -0,0 +1,573 @@
package se.ajpanton.notificationsmaster
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ArgbEvaluator
import android.animation.ValueAnimator
import android.graphics.Color
import android.graphics.BitmapFactory
import android.graphics.Paint
import android.os.Bundle
import android.content.ComponentName
import android.app.NotificationManager
import android.text.TextPaint
import android.text.TextUtils
import android.view.Gravity
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.DiffUtil
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
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<NotificationLogEntry>()
private var nextCursor: NewestLogCursor? = null
private var loading = false
private var noMoreRows = false
private var loadGeneration = 0
private val logAdapter = LogAdapter()
private lateinit var appFilterStore: LogAppFilterStore
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentViewLogsBinding.bind(view)
appFilterStore = LogAppFilterStore(requireContext())
binding!!.logsRefresh.setOnRefreshListener { loadLogs(reset = true) }
binding!!.logList.layoutManager = LinearLayoutManager(requireContext())
binding!!.logList.adapter = logAdapter
binding!!.logList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val scrollY = recyclerView.computeVerticalScrollOffset()
binding?.scrollToTop?.visibility = if (scrollY > dp(SCROLL_TO_TOP_THRESHOLD_DP)) View.VISIBLE else View.GONE
if (!noMoreRows && !loading && !recyclerView.canScrollVertically(1)) {
loadLogs(reset = false)
}
}
})
binding!!.scrollToTop.setOnClickListener {
binding?.logList?.smoothScrollToPosition(0)
}
loadLogs(reset = true)
}
override fun onResume() {
super.onResume()
loadLogs(reset = true)
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
private fun loadLogs(reset: Boolean) {
if (loading && !reset) return
if (!reset && noMoreRows) return
val context = requireContext().applicationContext
if (reset) {
rows.clear()
nextCursor = null
noMoreRows = false
loadGeneration++
}
val generation = loadGeneration
loading = true
binding?.logsRefresh?.isRefreshing = true
viewLifecycleOwner.lifecycleScope.launch {
val page = withContext(Dispatchers.IO) {
EncryptedNotificationLogStore(context).readNewest(nextCursor, PAGE_SIZE)
}
if (generation != loadGeneration) return@launch
nextCursor = page.nextCursor
noMoreRows = nextCursor == null
val visibleBefore = visibleRows(LogViewSettingsStore(requireContext()).load()).size
rows += page.entries
binding?.let(::render)
val visibleAfter = visibleRows(LogViewSettingsStore(requireContext()).load()).size
val skipFilteredPage = appFilterStore.selectedPackages().isNotEmpty() &&
visibleAfter == visibleBefore && !noMoreRows
loading = false
binding?.logsRefresh?.isRefreshing = false
if (skipFilteredPage) loadLogs(reset = false)
}
}
private fun render(view: FragmentViewLogsBinding) {
val settings = LogViewSettingsStore(requireContext()).load()
val visibleRows = visibleRows(settings)
view.emptyView.visibility = if (visibleRows.isEmpty()) View.VISIBLE else View.GONE
view.emptyView.text = if (hasNotificationAccess()) {
if (appFilterStore.selectedPackages().isEmpty()) "No logs yet." else "No logs match the app filter."
} else {
"Notification access is disabled. Enable it in Settings."
}
logAdapter.submit(visibleRows, settings, metadataColumnWidth(view.logList.width, visibleRows, settings))
}
/** Called by MainActivity's header action. Selections persist until Reset is chosen. */
fun showAppFilter() {
val appContext = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
val apps = withContext(Dispatchers.IO) {
val byPackage = linkedMapOf<String, LoggedApp>()
appFilterStore.selectedApps().forEach { app ->
val name = app.name.takeUnless { it == app.packageName } ?: runCatching {
appContext.packageManager.getApplicationLabel(
appContext.packageManager.getApplicationInfo(app.packageName, 0),
).toString()
}.getOrDefault(app.packageName)
byPackage[app.packageName] = LoggedApp(name, app.packageName)
}
EncryptedNotificationLogStore(appContext).forEachNewest { entry ->
byPackage[entry.packageName] = LoggedApp(entry.appName, entry.packageName)
}
byPackage.values.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
}
if (!isAdded) return@launch
val selected = appFilterStore.selectedPackages().toMutableSet()
var changed = false
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle("Filter logs by app")
.setMultiChoiceItems(
apps.map { "${it.name}\n${it.packageName}" }.toTypedArray(),
apps.map { it.packageName in selected }.toBooleanArray(),
) { _, which, checked ->
if (checked) selected += apps[which].packageName else selected -= apps[which].packageName
appFilterStore.save(apps.filter { it.packageName in selected }.map { app ->
LogAppFilterStore.App(app.name, app.packageName)
})
changed = true
}
.setNeutralButton("Reset") { _, _ ->
appFilterStore.clear()
changed = true
}
.setPositiveButton("Close", null)
.create()
dialog.setOnDismissListener {
if (changed) refreshAppFilter()
}
dialog.show()
}
}
private fun refreshAppFilter() {
(activity as? MainActivity)?.updateLogFilterAction()
loadLogs(reset = true)
}
private fun visibleRows(settings: LogViewSettings): List<NotificationLogEntry> {
val selectedPackages = appFilterStore.selectedPackages()
return rows.filter { entry ->
eventFor(entry.action) in settings.visibleEvents &&
(selectedPackages.isEmpty() || entry.packageName in selectedPackages)
}
}
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 {
bottomMargin = 0
}
fun renderExpanded(expanded: Boolean) {
removeAllViews()
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(entry.id)) expandedIds.remove(entry.id)
renderExpanded(entry.id in expandedIds)
if (collapsing) {
val highlight = { highlightCollapsedRow(this@apply) }
if (collapseAnchor != null) {
restoreCollapseAnchor(collapseAnchor, highlight)
} else {
this@apply.doOnPreDraw { highlight() }
}
}
Unit
}
val metadataCell = metadataCell(metadata, expanded, toggleExpanded, entry.id).apply {
layoutParams = LinearLayout.LayoutParams(
if (hasContents) metadataColumnWidth else LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
).apply {
if (hasContents) marginEnd = dp(COLUMN_GAP_DP)
}
}
if (metadata.isNotEmpty()) addView(metadataCell)
if (hasContents) {
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,
if (metadata.isNotEmpty()) 1f else 0f,
)
}
addView(contentsCell)
}
}
renderExpanded(entry.id in expandedIds)
setOnLongClickListener {
showDeleteDialog(entry.id)
}
}
private fun metadataCell(
values: List<MetadataValue>,
expanded: Boolean,
onToggleExpanded: () -> Unit,
entryId: String,
): LinearLayout = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(CELL_HORIZONTAL_PADDING_DP), dp(CELL_VERTICAL_PADDING_DP), dp(CELL_HORIZONTAL_PADDING_DP), dp(CELL_VERTICAL_PADDING_DP))
isClickable = true
isFocusable = true
values.forEach { value ->
addView(TextView(context).apply {
text = if (expanded && value.breakBeforePeriods) value.text.replace(".", "\u200B.") else value.text
maxLines = if (expanded) Int.MAX_VALUE else 1
ellipsize = if (expanded) null else TextUtils.TruncateAt.END
if (expanded) {
breakStrategy = android.graphics.text.LineBreaker.BREAK_STRATEGY_HIGH_QUALITY
hyphenationFrequency = android.text.Layout.HYPHENATION_FREQUENCY_NONE
}
textSize = value.textSize
if (value.indented) setPadding(dp(8), 0, 0, 0)
setLineSpacing(0f, 1f)
if (value.bold) setTypeface(typeface, android.graphics.Typeface.BOLD)
})
}
setOnClickListener { onToggleExpanded() }
setOnLongClickListener { showDeleteDialog(entryId) }
}
private fun contentsCell(
entry: NotificationLogEntry,
contents: String,
collapsedLines: Int,
expanded: Boolean,
onToggleExpanded: () -> Unit,
): LinearLayout = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, dp(CELL_VERTICAL_PADDING_DP), 0, dp(CELL_VERTICAL_PADDING_DP))
isClickable = true
isFocusable = true
val message = TextView(context).apply {
text = contents
maxLines = if (expanded) Int.MAX_VALUE else collapsedLines
ellipsize = if (expanded) null else TextUtils.TruncateAt.END
setLineSpacing(0f, 1f)
}
addView(message)
if (expanded && entry.action == NotificationAction.EDITED && !entry.previousContents.isNullOrEmpty()) {
addView(previousDivider())
addView(TextView(context).apply {
text = entry.previousContents
setLineSpacing(0f, 1f)
})
}
if (expanded && entry.imageId != null) {
val imageId = entry.imageId
val appContext = requireContext().applicationContext
val contentsContainer = this
viewLifecycleOwner.lifecycleScope.launch {
val bitmap = withContext(Dispatchers.IO) {
EncryptedImageStore(appContext).read(imageId)?.let { bytes ->
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}
}
if (contentsContainer.isAttachedToWindow && bitmap != null) {
contentsContainer.addView(ImageView(contentsContainer.context).apply {
setImageBitmap(bitmap)
adjustViewBounds = true
maxHeight = dp(240)
contentDescription = "Notification image"
setPadding(0, dp(4), 0, 0)
setOnClickListener {
if (isAdded) {
ImageViewerDialogFragment.newInstance(imageId).show(parentFragmentManager, IMAGE_VIEWER_TAG)
}
}
})
}
}
}
setOnClickListener { onToggleExpanded() }
setOnLongClickListener { showDeleteDialog(entry.id) }
}
private fun previousDivider(): LinearLayout = LinearLayout(requireContext()).apply {
gravity = Gravity.CENTER_VERTICAL
setPadding(0, dp(8), 0, dp(4))
fun line() = View(context).apply {
setBackgroundColor(ContextCompat.getColor(context, R.color.log_row_separator))
layoutParams = LinearLayout.LayoutParams(0, dp(1), 1f)
}
addView(line())
addView(TextView(context).apply {
text = "Previous"
textSize = 12f
setTextColor(ContextCompat.getColor(context, R.color.on_surface))
setBackgroundColor(ContextCompat.getColor(context, R.color.window_background))
setPadding(dp(8), 0, dp(8), 0)
})
addView(line())
}
private fun metadataValues(entry: NotificationLogEntry, settings: LogViewSettings): List<MetadataValue> = buildList {
if (LogField.TIMESTAMP in settings.visibleFields) add(MetadataValue(timestamp(entry.recordedAtEpochMillis, settings, entry.eventTimeZoneId), 12f, isTimestamp = true))
if (LogField.APP_NAME in settings.visibleFields) add(MetadataValue(entry.appName, 15f, indented = true, bold = true))
if (LogField.PACKAGE_NAME in settings.visibleFields) add(MetadataValue(entry.packageName, 12f, indented = true, breakBeforePeriods = true))
if (LogField.ACTION in settings.visibleFields) add(MetadataValue(actionLabel(entry.action), 14f, indented = true))
}
private fun actionLabel(action: NotificationAction): String = when (action) {
NotificationAction.APPEARED -> "Appeared"
NotificationAction.ALREADY_ACTIVE -> "Already active"
NotificationAction.EDITED -> "Edited"
NotificationAction.APP_CANCELLED -> "App cancelled"
NotificationAction.APP_CANCELLED_ALL -> "App cancelled all"
NotificationAction.USER_DISMISSED -> "User dismissed"
NotificationAction.USER_DISMISSED_ALL -> "User dismissed all"
NotificationAction.USER_CLICKED -> "User opened"
else -> action.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase)
}
private fun eventFor(action: NotificationAction): DisplayEvent = when (action) {
NotificationAction.APPEARED, NotificationAction.ALREADY_ACTIVE -> DisplayEvent.APPEARING
NotificationAction.EDITED -> DisplayEvent.EDITS
else -> DisplayEvent.DISAPPEARING
}
private fun hasNotificationAccess(): Boolean = requireContext()
.getSystemService(NotificationManager::class.java)
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
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
val widestMetadata = values
.maxOfOrNull(::metadataTextWidth)
?: 0
val timestampMinimumWidth = maxOf(
dp(MINIMUM_METADATA_COLUMN_DP),
widestTimestamp + dp(CELL_HORIZONTAL_PADDING_DP * 2),
)
val preferredWidth = maxOf(timestampMinimumWidth, widestMetadata + dp(CELL_HORIZONTAL_PADDING_DP * 2))
if (availableWidth <= 0) return preferredWidth
val maximumWidth = maxOf(
timestampMinimumWidth,
(availableWidth * MAXIMUM_METADATA_COLUMN_FRACTION).toInt(),
)
return minOf(preferredWidth, maximumWidth)
}
private fun metadataTextWidth(value: MetadataValue): Int = TextPaint(Paint.ANTI_ALIAS_FLAG).run {
textSize = sp(value.textSize)
typeface = if (value.bold) android.graphics.Typeface.DEFAULT_BOLD else android.graphics.Typeface.DEFAULT
measureText(value.text).toInt() + if (value.indented) dp(METADATA_INDENT_DP) else 0
}
private fun rowSeparator(): View = View(requireContext()).apply {
background = androidx.core.content.ContextCompat.getDrawable(context, R.drawable.log_row_separator)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(1)).apply {
topMargin = dp(ROW_SEPARATOR_MARGIN_DP)
bottomMargin = dp(ROW_SEPARATOR_MARGIN_DP)
}
}
private inner class LogAdapter : RecyclerView.Adapter<LogAdapter.Holder>() {
private var rows: List<NotificationLogEntry> = emptyList()
private var settings = LogViewSettings()
private var metadataWidth = 0
fun submit(rows: List<NotificationLogEntry>, settings: LogViewSettings, metadataWidth: Int) {
val previous = this.rows
this.rows = rows
this.settings = settings
this.metadataWidth = metadataWidth
DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize() = previous.size
override fun getNewListSize() = rows.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
previous[oldItemPosition].id == rows[newItemPosition].id
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
previous[oldItemPosition] == rows[newItemPosition]
}).dispatchUpdatesTo(this)
notifyItemRangeChanged(0, itemCount)
}
override fun onCreateViewHolder(parent: android.view.ViewGroup, viewType: Int): Holder {
val root = LinearLayout(parent.context).apply { orientation = LinearLayout.VERTICAL }
val content = LinearLayout(parent.context)
root.addView(content)
root.addView(rowSeparator())
return Holder(root, content)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
holder.content.removeAllViews()
holder.content.addView(logRowView(rows[position], settings, metadataWidth))
holder.separator.visibility = if (position == itemCount - 1) View.GONE else View.VISIBLE
}
override fun getItemCount(): Int = rows.size
inner class Holder(val root: LinearLayout, val content: LinearLayout) : RecyclerView.ViewHolder(root) {
val separator: View get() = root.getChildAt(1)
}
}
private fun timestamp(value: Long, settings: LogViewSettings, eventTimeZoneId: String?): String =
TimestampFormatter.format(requireContext(), value, settings, eventTimeZoneId)
private fun showDeleteDialog(entryId: String): Boolean {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Delete this log?")
.setMessage("This permanently removes this log and its copied image, if any.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Delete") { _, _ ->
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) {
EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId)
}
loadLogs(reset = true)
}
}
.show()
return true
}
private fun captureCollapseAnchor(collapsingRow: View): CollapseAnchor? {
val currentBinding = binding ?: return null
val list = currentBinding.logList
val manager = list.layoutManager as? LinearLayoutManager ?: return null
val collapsingItem = list.findContainingItemView(collapsingRow) ?: return null
val collapsingPosition = list.getChildAdapterPosition(collapsingItem)
val topPosition = manager.findFirstVisibleItemPosition()
val bottomPosition = manager.findLastVisibleItemPosition()
val topRow = manager.findViewByPosition(topPosition)
val bottomRow = manager.findViewByPosition(bottomPosition)
return when {
topPosition == collapsingPosition && bottomPosition == collapsingPosition -> CollapseAnchor.CENTER_COLLAPSED_ROW(collapsingPosition)
topPosition == collapsingPosition && bottomRow != null ->
CollapseAnchor.KEEP_BOTTOM_EDGE(bottomPosition, bottomRow.bottom - list.height)
bottomPosition == collapsingPosition && topRow != null ->
CollapseAnchor.KEEP_TOP_EDGE(topPosition, topRow.top)
else -> null
}
}
private fun restoreCollapseAnchor(anchor: CollapseAnchor, afterRestore: () -> Unit) {
val currentBinding = binding ?: return
val list = currentBinding.logList
val manager = list.layoutManager as? LinearLayoutManager ?: return
list.doOnPreDraw {
if (binding !== currentBinding) return@doOnPreDraw
val row = manager.findViewByPosition(anchor.position) ?: return@doOnPreDraw
val delta = when (anchor) {
is CollapseAnchor.CENTER_COLLAPSED_ROW -> row.top + row.height / 2 - list.height / 2
is CollapseAnchor.KEEP_TOP_EDGE -> row.top - anchor.offset
is CollapseAnchor.KEEP_BOTTOM_EDGE -> row.bottom - list.height - anchor.offset
}
list.scrollBy(0, delta)
afterRestore()
}
}
private fun highlightCollapsedRow(row: View) {
if (!row.isAttachedToWindow) return
val baseColor = ContextCompat.getColor(row.context, R.color.primary)
val startColor = Color.argb(
COLLAPSE_HIGHLIGHT_ALPHA,
Color.red(baseColor),
Color.green(baseColor),
Color.blue(baseColor),
)
val endColor = Color.argb(0, Color.red(baseColor), Color.green(baseColor), Color.blue(baseColor))
row.setBackgroundColor(startColor)
ValueAnimator.ofObject(ArgbEvaluator(), startColor, endColor).apply {
duration = COLLAPSE_HIGHLIGHT_DURATION_MILLIS
addUpdateListener { row.setBackgroundColor(it.animatedValue as Int) }
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
row.background = null
}
})
start()
}
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private fun sp(value: Float): Float = android.util.TypedValue.applyDimension(
android.util.TypedValue.COMPLEX_UNIT_SP,
value,
resources.displayMetrics,
)
private data class LoggedApp(val name: String, val packageName: String)
private data class MetadataValue(
val text: String,
val textSize: Float,
val indented: Boolean = false,
val bold: Boolean = false,
val breakBeforePeriods: Boolean = false,
val isTimestamp: Boolean = false,
)
private sealed interface CollapseAnchor {
val position: Int
data class CENTER_COLLAPSED_ROW(override val position: Int) : CollapseAnchor
data class KEEP_TOP_EDGE(override val position: Int, val offset: Int) : CollapseAnchor
data class KEEP_BOTTOM_EDGE(override val position: Int, val offset: Int) : CollapseAnchor
}
private companion object {
const val SCROLL_TO_TOP_THRESHOLD_DP = 120
const val PAGE_SIZE = 80
const val IMAGE_VIEWER_TAG = "image-viewer"
const val MINIMUM_METADATA_COLUMN_DP = 120
const val CELL_HORIZONTAL_PADDING_DP = 8
const val CELL_VERTICAL_PADDING_DP = 6
const val COLUMN_GAP_DP = 6
const val ROW_SEPARATOR_MARGIN_DP = 4
const val METADATA_INDENT_DP = 8
const val MAXIMUM_METADATA_COLUMN_FRACTION = 0.45f
const val COLLAPSE_HIGHLIGHT_ALPHA = 52
const val COLLAPSE_HIGHLIGHT_DURATION_MILLIS = 450L
}
}
@@ -0,0 +1,164 @@
package se.ajpanton.notificationsmaster
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Matrix
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import androidx.appcompat.widget.AppCompatImageView
/** A fit-to-screen image which supports pinch zoom and one-finger panning. */
class ZoomImageView @JvmOverloads constructor(
context: Context,
attributes: AttributeSet? = null,
) : AppCompatImageView(context, attributes) {
var onSingleTap: (() -> Unit)? = null
private val transform = Matrix()
private val bounds = RectF()
private var minimumScale = 1f
private var currentScale = 1f
private var lastX = 0f
private var lastY = 0f
private var dragging = false
private var lastFocusX = 0f
private var lastFocusY = 0f
private var pointerCount = 0
private var quickScaleGesture = false
private val scaleDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
lastFocusX = detector.focusX
lastFocusY = detector.focusY
return true
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
// Focus movement while two fingers are down is two-finger panning.
// Quick scale has one pointer, so it remains pure zoom around its
// built-in double-tap anchor.
setScaleAround((currentScale * detector.scaleFactor).coerceIn(minimumScale, minimumScale * MAX_ZOOM), detector.focusX, detector.focusY)
if (pointerCount >= 2) {
transform.postTranslate(detector.focusX - lastFocusX, detector.focusY - lastFocusY)
}
lastFocusX = detector.focusX
lastFocusY = detector.focusY
constrain()
return true
}
})
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(event: MotionEvent): Boolean {
onSingleTap?.invoke()
return true
}
override fun onDoubleTap(event: MotionEvent): Boolean {
quickScaleGesture = true
return true
}
override fun onDoubleTapEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
quickScaleGesture = false
}
return true
}
})
init {
scaleType = ScaleType.MATRIX
scaleDetector.isQuickScaleEnabled = true
}
override fun setImageBitmap(bitmap: Bitmap?) {
super.setImageBitmap(bitmap)
post(::resetToFit)
}
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
resetToFit()
}
override fun onTouchEvent(event: MotionEvent): Boolean {
pointerCount = event.pointerCount
scaleDetector.onTouchEvent(event)
gestureDetector.onTouchEvent(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastX = event.x
lastY = event.y
dragging = false
}
MotionEvent.ACTION_MOVE -> if (event.pointerCount == 1 && !scaleDetector.isInProgress && !quickScaleGesture) {
val dx = event.x - lastX
val dy = event.y - lastY
if (dx != 0f || dy != 0f) {
dragging = true
transform.postTranslate(dx, dy)
constrain()
}
lastX = event.x
lastY = event.y
}
MotionEvent.ACTION_POINTER_UP -> {
val remainingPointer = if (event.actionIndex == 0) 1 else 0
lastX = event.getX(remainingPointer)
lastY = event.getY(remainingPointer)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (dragging) performClick()
}
}
return true
}
override fun performClick(): Boolean = super.performClick()
private fun setScaleAround(scale: Float, pivotX: Float, pivotY: Float) {
transform.postScale(scale / currentScale, scale / currentScale, pivotX, pivotY)
currentScale = scale
}
private fun resetToFit() {
val drawable = drawable ?: return
if (width == 0 || height == 0 || drawable.intrinsicWidth <= 0 || drawable.intrinsicHeight <= 0) return
minimumScale = minOf(width.toFloat() / drawable.intrinsicWidth, height.toFloat() / drawable.intrinsicHeight)
currentScale = minimumScale
transform.reset()
transform.postScale(minimumScale, minimumScale)
transform.postTranslate(
(width - drawable.intrinsicWidth * minimumScale) / 2f,
(height - drawable.intrinsicHeight * minimumScale) / 2f,
)
imageMatrix = transform
}
private fun constrain() {
val drawable = drawable ?: return
bounds.set(0f, 0f, drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat())
transform.mapRect(bounds)
val dx = when {
bounds.width() <= width -> width / 2f - bounds.centerX()
bounds.left > 0f -> -bounds.left
bounds.right < width -> width - bounds.right
else -> 0f
}
val dy = when {
bounds.height() <= height -> height / 2f - bounds.centerY()
bounds.top > 0f -> -bounds.top
bounds.bottom < height -> height - bounds.bottom
else -> 0f
}
transform.postTranslate(dx, dy)
imageMatrix = transform
}
private companion object {
const val MAX_ZOOM = 6f
}
}
@@ -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
}
@@ -0,0 +1,17 @@
package se.ajpanton.notificationsmaster.capture
import se.ajpanton.notificationsmaster.model.NotificationAction
/** Applies the global, Android-provided group-summary classification without inspecting text. */
object GroupSummaryPolicy {
fun shouldLog(snapshot: NotificationSnapshot, logGroupSummaries: Boolean): Boolean =
shouldLog(snapshot, action = null, logGroupSummaries)
fun shouldLog(
snapshot: NotificationSnapshot,
action: NotificationAction?,
logGroupSummaries: Boolean,
): Boolean = logGroupSummaries || (
!snapshot.isGroupSummary && action != NotificationAction.GROUP_SUMMARY_CANCELLED
)
}
@@ -0,0 +1,241 @@
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.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
class NotificationCaptureService : NotificationListenerService() {
private val activeNotifications = mutableMapOf<String, NotificationSnapshot>()
private lateinit var logStore: EncryptedNotificationLogStore
private lateinit var writeExecutor: ExecutorService
private lateinit var ruleStore: LoggingRuleStore
private lateinit var captureSettings: CaptureSettingsStore
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()
logStore = EncryptedNotificationLogStore(this)
ruleStore = LoggingRuleStore(this)
captureSettings = CaptureSettingsStore(this)
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")
}
}
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(this, snapshot.packageName)
activeNotifications[snapshot.key] = snapshot
record(
snapshot,
NotificationAction.ALREADY_ACTIVE,
LoggingType.APPEARING,
includeContents = true,
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
)
}
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
if (DebugNotifications.isManaged(this, sbn)) return
val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(this, snapshot.packageName)
val previous = activeNotifications.put(snapshot.key, snapshot)
when {
previous == null -> {
record(
snapshot,
NotificationAction.APPEARED,
LoggingType.APPEARING,
includeContents = true,
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
)
dispatchAlert(snapshot, AlertSource.NOTIFICATION_POST)
}
NotificationChangeClassifier.isMeaningfulEdit(previous, snapshot) -> {
if (!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates)) {
record(
snapshot,
NotificationAction.EDITED,
LoggingType.EDITS,
includeContents = true,
previousSnapshot = previous,
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
)
}
dispatchAlert(snapshot, AlertSource.NOTIFICATION_UPDATE)
}
}
}
override fun onNotificationRemoved(
sbn: StatusBarNotification,
rankingMap: RankingMap,
reason: Int,
) {
if (DebugNotifications.isManaged(this, sbn)) return
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn)
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()
}
private fun record(
snapshot: NotificationSnapshot,
action: NotificationAction,
loggingType: LoggingType,
includeContents: Boolean,
previousSnapshot: NotificationSnapshot? = null,
imageBytes: (() -> ByteArray?)? = null,
) {
if (!GroupSummaryPolicy.shouldLog(snapshot, action, captureSettings.logGroupSummaries)) return
if (!loggingEnabled(loggingType, snapshot.packageName) ||
!NotificationRuleEvaluator.allows(appFilterStore.load(), snapshot.packageName)
) return
val appName = appName(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(),
eventTimeZoneId = java.util.TimeZone.getDefault().id,
packageName = snapshot.packageName,
appName = appName,
action = action,
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 {
try {
if (retainedImageBytes != null) {
try {
imageStore.save(entry.imageId!!, retainedImageBytes)
} catch (error: Exception) {
Log.w(TAG, "Could not retain notification image; keeping the text event", error)
logStore.append(entry.copy(imageId = null))
return@execute
}
}
logStore.append(entry)
} catch (error: Exception) {
entry.imageId?.let(imageStore::delete)
Log.e(TAG, "Could not persist notification log entry", error)
}
}
}
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 loggingEnabled(type: LoggingType, packageName: String): Boolean {
val globalEnabled = ruleStore.ruleFor(type).enabled
return if (type in LoggingType.eventTypes) {
perAppEventSettings.isEnabled(packageName, type, globalEnabled)
} else {
globalEnabled
}
}
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 {
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
packageManager.getApplicationLabel(applicationInfo).toString()
} catch (error: PackageManager.NameNotFoundException) {
Log.w(TAG, "Package disappeared while its notification was being recorded: $packageName", error)
packageName
}
private fun actionForRemoval(reason: Int): NotificationAction = when (reason) {
REASON_APP_CANCEL -> NotificationAction.APP_CANCELLED
REASON_APP_CANCEL_ALL -> NotificationAction.APP_CANCELLED_ALL
REASON_CANCEL -> NotificationAction.USER_DISMISSED
REASON_CANCEL_ALL -> NotificationAction.USER_DISMISSED_ALL
REASON_CLICK -> NotificationAction.USER_CLICKED
REASON_LISTENER_CANCEL -> NotificationAction.LISTENER_DISMISSED
REASON_LISTENER_CANCEL_ALL -> NotificationAction.LISTENER_DISMISSED_ALL
REASON_SNOOZED -> NotificationAction.SNOOZED
REASON_TIMEOUT -> NotificationAction.TIMED_OUT
REASON_CHANNEL_BANNED -> NotificationAction.CHANNEL_BANNED
REASON_CHANNEL_REMOVED -> NotificationAction.CHANNEL_REMOVED
REASON_PACKAGE_BANNED -> NotificationAction.PACKAGE_BANNED
REASON_PACKAGE_CHANGED -> NotificationAction.PACKAGE_CHANGED
REASON_PACKAGE_SUSPENDED -> NotificationAction.PACKAGE_SUSPENDED
REASON_PROFILE_TURNED_OFF -> NotificationAction.PROFILE_TURNED_OFF
REASON_USER_STOPPED -> NotificationAction.USER_STOPPED
REASON_GROUP_SUMMARY_CANCELED -> NotificationAction.GROUP_SUMMARY_CANCELLED
REASON_GROUP_OPTIMIZATION -> NotificationAction.GROUP_OPTIMIZED
REASON_UNAUTOBUNDLED -> NotificationAction.UNAUTOBUNDLED
REASON_CLEAR_DATA -> NotificationAction.CLEAR_DATA
REASON_ASSISTANT_CANCEL -> NotificationAction.ASSISTANT_CANCELLED
REASON_LOCKDOWN -> NotificationAction.LOCKDOWN
REASON_ERROR -> NotificationAction.SYSTEM_ERROR
else -> NotificationAction.OTHER_REMOVAL
}
private companion object {
const val TAG = "NotificationCapture"
const val MAX_CONTENT_CHARACTERS = 16_000
}
}
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.capture package se.ajpanton.notificationsmaster.capture
/** Keeps update classification deterministic and independently testable. */ /** Keeps update classification deterministic and independently testable. */
object NotificationChangeClassifier { object NotificationChangeClassifier {
@@ -0,0 +1,153 @@
package se.ajpanton.notificationsmaster.capture
import android.app.Notification
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Icon
import android.os.Bundle
import android.service.notification.StatusBarNotification
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
data class NotificationSnapshot(
val key: String,
val packageName: String,
val textContents: String?,
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 }
return genericContents(
title = extras.getCharSequence(Notification.EXTRA_TITLE),
text = extras.getCharSequence(Notification.EXTRA_TEXT),
bigText = extras.getCharSequence(Notification.EXTRA_BIG_TEXT),
subText = extras.getCharSequence(Notification.EXTRA_SUB_TEXT),
summaryText = extras.getCharSequence(Notification.EXTRA_SUMMARY_TEXT),
textLines = extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES).orEmpty().asList(),
)
}
/** Uses BigTextStyle's expanded content instead of its alternate collapsed rendering. */
internal fun genericContents(
title: CharSequence?,
text: CharSequence?,
bigText: CharSequence?,
subText: CharSequence?,
summaryText: CharSequence?,
textLines: List<CharSequence?>,
): String? {
val parts = linkedSetOf<String>()
title?.addTo(parts)
(bigText.takeIf { it.hasText() } ?: text)?.addTo(parts)
subText?.addTo(parts)
summaryText?.addTo(parts)
textLines.forEach { it?.addTo(parts) }
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
}
/** MessagingStyle also populates generic title/text fields with an alternate rendering. */
private fun messagingContents(extras: Bundle): String? {
val parts = linkedSetOf<String>()
listOf(Notification.EXTRA_HISTORIC_MESSAGES, Notification.EXTRA_MESSAGES).forEach { key ->
Notification.MessagingStyle.Message.getMessagesFromBundleArray(
extras.getParcelableArray(key, Bundle::class.java),
).forEach { message ->
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts)
}
}
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
}
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,
textContents = extract(notification),
hasImage = extras?.let {
it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON)
} == true,
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
extras?.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE, false) == true ||
extras?.let {
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
val picture = extras.getParcelable(Notification.EXTRA_PICTURE, Bitmap::class.java)
val pictureIcon = extras.getParcelable(Notification.EXTRA_PICTURE_ICON, Icon::class.java)
return picture?.toPng() ?: pictureIcon?.let { icon -> icon.loadDrawable(context)?.toBitmap()?.toPng() }
}
private fun Drawable.toBitmap(): Bitmap? = when (this) {
is BitmapDrawable -> bitmap
else -> runCatching {
val sourceWidth = maxOf(1, intrinsicWidth)
val sourceHeight = maxOf(1, intrinsicHeight)
val scale = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(sourceWidth, sourceHeight))
val width = (sourceWidth * scale).toInt()
val height = (sourceHeight * scale).toInt()
createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmap ->
setBounds(0, 0, width, height)
draw(Canvas(bitmap))
}
}.getOrNull()
}
private fun Bitmap.toPng(): ByteArray? = runCatching {
val scaleFactor = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(width, height))
val bitmap = if (scaleFactor < 1f) scale((width * scaleFactor).toInt(), (height * scaleFactor).toInt()) else this
java.io.ByteArrayOutputStream().use { output ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
output.toByteArray()
}
}.getOrNull()
private fun CharSequence.addTo(parts: MutableSet<String>) {
toString().trim().takeIf { it.isNotEmpty() }?.let(parts::add)
}
private fun CharSequence?.hasText(): Boolean = !this.isNullOrBlank()
private const val MAX_IMAGE_DIMENSION = 1600
}
@@ -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.Cipher
import javax.crypto.SecretKey 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.content.Context
import android.util.AtomicFile import android.util.AtomicFile
@@ -47,6 +47,11 @@ class EncryptedImageStore(context: Context) {
} }
} }
fun exists(id: String): Boolean {
require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." }
return File(directory, "$id.bin").isFile
}
fun delete(id: String) { fun delete(id: String) {
require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." } require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." }
File(directory, "$id.bin").delete() File(directory, "$id.bin").delete()
@@ -0,0 +1,254 @@
package se.ajpanton.notificationsmaster.data
import android.content.Context
import android.util.AtomicFile
import se.ajpanton.notificationsmaster.model.NotificationLogEntry
import se.ajpanton.notificationsmaster.settings.StorageLimitsStore
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileNotFoundException
data class NewestLogCursor(
val chunkName: String,
val entryIndex: Int,
)
data class NotificationLogPage(
val entries: List<NotificationLogEntry>,
val nextCursor: NewestLogCursor?,
)
/**
* Independently encrypted log chunks. Reading or appending a record needs only
* one bounded chunk; older chunks stay on disk until requested by the viewer.
*/
class EncryptedNotificationLogStore(context: Context) {
private val appContext = context.applicationContext
private val directory = File(appContext.filesDir, DIRECTORY_NAME).also(File::mkdirs)
private val imageDirectory = File(appContext.filesDir, IMAGE_DIRECTORY_NAME)
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
fun readNewest(cursor: NewestLogCursor?, limit: Int): NotificationLogPage = synchronized(lock) {
require(limit > 0)
val result = ArrayList<NotificationLogEntry>(limit)
val chunks = chunkFiles().asReversed()
var chunkIndex = cursor?.let { saved -> chunks.indexOfFirst { it.name == saved.chunkName } } ?: 0
var entryIndex = cursor?.entryIndex ?: Int.MAX_VALUE
if (chunkIndex < 0) return@synchronized NotificationLogPage(emptyList(), null)
while (chunkIndex < chunks.size) {
val chunk = chunks[chunkIndex]
val entries = readChunk(chunk)
var index = minOf(entryIndex, entries.lastIndex)
while (index >= 0 && result.size < limit) {
result += entries[index--]
}
if (result.size == limit) {
val next = if (index >= 0) {
NewestLogCursor(chunk.name, index)
} else {
chunks.getOrNull(chunkIndex + 1)?.let { NewestLogCursor(it.name, Int.MAX_VALUE) }
}
return@synchronized NotificationLogPage(result, next)
}
chunkIndex++
entryIndex = Int.MAX_VALUE
}
NotificationLogPage(result, null)
}
/**
* Visits the current history newest first without collecting it in memory.
* Each encrypted chunk is decrypted while holding the store lock, then its
* entries are passed to [action] after releasing the lock so capture is not
* held up by a slow export destination.
*/
fun forEachNewest(action: (NotificationLogEntry) -> Unit) {
val chunks = synchronized(lock) { chunkFiles().asReversed() }
chunks.forEach { chunk ->
val entries = synchronized(lock) {
if (chunk.exists()) readChunk(chunk).asReversed() else emptyList()
}
entries.forEach(action)
}
}
fun append(entry: NotificationLogEntry) = synchronized(lock) {
val chunks = chunkFiles()
val newest = chunks.lastOrNull()
val current = newest?.let(::readChunk).orEmpty()
val combined = NotificationLogEntryJson.encode(current + entry)
if (newest == null || combined.size <= MAX_CHUNK_PLAINTEXT_BYTES) {
writeChunk(newest ?: nextChunkFile(chunks), combined)
} else {
writeChunk(nextChunkFile(chunks), NotificationLogEntryJson.encode(listOf(entry)))
}
enforceLimits()
}
fun clear() = synchronized(lock) {
clearChunksOnly()
imageDirectory.listFiles()?.forEach(File::delete)
}
fun delete(id: String): Boolean = synchronized(lock) {
chunkFiles().forEach { chunk ->
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, NotificationLogEntryJson.encode(retained))
entry.imageId?.let(::deleteImage)
return true
}
false
}
/** Removes failed-write remnants and images no current log entry references. */
fun removeOrphanedImages() = synchronized(lock) {
if (imageDirectory.listFiles().isNullOrEmpty()) return
val referenced = mutableSetOf<String>()
chunkFiles().forEach { chunk ->
readChunk(chunk).mapNotNullTo(referenced) { it.imageId }
}
removeUnreferencedImages(referenced)
}
fun enforceLimits() = synchronized(lock) {
val limits = StorageLimitsStore(appContext).load()
trimLogBytes(limits.logBytes)
trimImageBytes(limits.imageBytes)
}
private fun trimLogBytes(limit: Long) {
var chunks = chunkFiles()
var totalBytes = chunks.sumOf(File::length)
while (totalBytes > limit && chunks.isNotEmpty()) {
val oldest = chunks.first()
val entries = readChunk(oldest)
if (entries.isEmpty()) {
oldest.delete()
} else if (chunks.size > 1) {
entries.mapNotNull { it.imageId }.forEach(::deleteImage)
oldest.delete()
} else {
var retained = entries
while (totalBytes > limit && retained.isNotEmpty()) {
retained.first().imageId?.let(::deleteImage)
retained = retained.drop(1)
if (retained.isEmpty()) oldest.delete()
else writeChunk(oldest, NotificationLogEntryJson.encode(retained))
totalBytes = chunkFiles().sumOf(File::length)
}
}
chunks = chunkFiles()
totalBytes = chunks.sumOf(File::length)
}
}
private fun trimImageBytes(limit: Long) {
var imageBytes = imageDirectory.listFiles()?.filter(File::isFile)?.sumOf(File::length) ?: 0L
if (imageBytes <= limit) return
chunkFiles().forEach { chunk ->
if (imageBytes <= limit) return@forEach
readChunk(chunk).forEach { entry ->
if (imageBytes > limit && entry.imageId != null) {
val image = File(imageDirectory, "${entry.imageId}.bin")
imageBytes -= image.length()
// Keep imageId and the original `[image]` text marker in the
// log. The viewer and HTML exporter use the marker whenever
// this retained file is no longer available.
image.delete()
}
}
}
}
private fun removeUnreferencedImages(referenced: Set<String>) {
imageDirectory.listFiles()?.forEach { image ->
val imageId = image.name.removeSuffix(".bin")
if (image.name.endsWith(".new") ||
!image.name.endsWith(".bin") ||
!imageId.matches(IMAGE_ID_PATTERN) ||
imageId !in referenced
) {
image.delete()
}
}
}
private fun chunkFiles(): List<File> = directory.listFiles()
?.filter { it.isFile && it.name.startsWith(CHUNK_PREFIX) && it.name.endsWith(CHUNK_SUFFIX) }
?.sortedBy { it.name }
?: emptyList()
private fun nextChunkFile(chunks: List<File>): File {
val next = chunks.lastOrNull()?.name?.removePrefix(CHUNK_PREFIX)?.removeSuffix(CHUNK_SUFFIX)?.toLongOrNull()?.plus(1) ?: 0L
return chunkFile(next)
}
private fun chunkFile(sequence: Long): File = File(directory, "$CHUNK_PREFIX${sequence.toString().padStart(CHUNK_NAME_WIDTH, '0')}$CHUNK_SUFFIX")
private fun readChunk(file: File): List<NotificationLogEntry> {
val input = try {
AtomicFile(file).openRead()
} catch (_: FileNotFoundException) {
return emptyList()
}
DataInputStream(BufferedInputStream(input)).use { stream ->
require(stream.readInt() == FILE_VERSION) { "Unsupported encrypted notification-log chunk." }
val initializationVector = readByteArray(stream, MAX_IV_BYTES)
val cipherText = readByteArray(stream, MAX_CHUNK_CIPHER_TEXT_BYTES)
return NotificationLogEntryJson.decode(cipher.decrypt(EncryptedPayload(initializationVector, cipherText)))
}
}
private fun writeChunk(file: File, encoded: ByteArray) {
val payload = cipher.encrypt(encoded)
val atomicFile = AtomicFile(file)
val output = atomicFile.startWrite()
try {
val stream = DataOutputStream(BufferedOutputStream(output))
stream.writeInt(FILE_VERSION)
stream.writeInt(payload.initializationVector.size)
stream.write(payload.initializationVector)
stream.writeInt(payload.cipherText.size)
stream.write(payload.cipherText)
stream.flush()
atomicFile.finishWrite(output)
} catch (error: Exception) {
atomicFile.failWrite(output)
throw error
}
}
private fun readByteArray(stream: DataInputStream, maximumLength: Int): ByteArray {
val length = stream.readInt()
require(length in 1..maximumLength) { "Invalid encrypted notification-log field length." }
return ByteArray(length).also(stream::readFully)
}
private fun clearChunksOnly() {
chunkFiles().forEach(File::delete)
}
private fun deleteImage(id: String) {
if (id.matches(IMAGE_ID_PATTERN)) File(imageDirectory, "$id.bin").delete()
}
private companion object {
val lock = Any()
const val DIRECTORY_NAME = "notification-log"
const val IMAGE_DIRECTORY_NAME = "notification-images"
const val CHUNK_PREFIX = "chunk-"
const val CHUNK_SUFFIX = ".bin"
const val CHUNK_NAME_WIDTH = 12
const val FILE_VERSION = 1
const val MAX_IV_BYTES = 32
const val MAX_CHUNK_CIPHER_TEXT_BYTES = 1024 * 1024
const val MAX_CHUNK_PLAINTEXT_BYTES = 256 * 1024
val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}")
}
}
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.data package se.ajpanton.notificationsmaster.data
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties 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.JSONArray
import org.json.JSONObject import org.json.JSONObject
import se.ajpanton.notificationlog.model.NotificationAction import se.ajpanton.notificationsmaster.model.NotificationAction
import se.ajpanton.notificationlog.model.NotificationLogEntry import se.ajpanton.notificationsmaster.model.NotificationLogEntry
/** Versioned, private plaintext representation which is encrypted before it reaches disk. */ /** Versioned, private plaintext representation which is encrypted before it reaches disk. */
object NotificationLogEntryJson { 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)
}
@@ -0,0 +1,92 @@
package se.ajpanton.notificationsmaster.export
import android.content.Context
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 = (
listOf(csvHeader(settings)) + entries.sortedByDescending { it.recordedAtEpochMillis }.map { csvRow(it, settings, context) }
).joinToString("\n")
fun formatted(entries: List<NotificationLogEntry>, settings: LogViewSettings, context: Context? = null): String {
val rows = listOf(headers(settings)) + entries.sortedByDescending { it.recordedAtEpochMillis }.map { values(it, settings, context) }
val widths = rows.fold(emptyList<Int>()) { current, row -> row.mapIndexed { i, value -> maxOf(current.getOrElse(i) { 0 }, value.length) } }
return rows.joinToString("\n") { formattedRow(it, widths) }
}
fun headers(settings: LogViewSettings): List<String> = fields(settings).map(::header)
fun values(entry: NotificationLogEntry, settings: LogViewSettings, context: Context? = null): List<String> = fields(settings).map { field -> when (field) {
LogField.TIMESTAMP -> timestamp(context, entry.recordedAtEpochMillis, settings, entry.eventTimeZoneId)
LogField.APP_NAME -> entry.appName
LogField.PACKAGE_NAME -> entry.packageName
LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ')
LogField.CONTENTS -> entry.contents ?: ""
} }
fun csvHeader(settings: LogViewSettings): String = csvLine(headers(settings))
fun csvRow(entry: NotificationLogEntry, settings: LogViewSettings, context: Context? = null): String = csvLine(values(entry, settings, context))
fun formattedRow(values: List<String>, widths: List<Int>): String =
values.mapIndexed { index, value -> value.padEnd(widths[index]) }.joinToString(" ")
fun htmlStart(settings: LogViewSettings): String = buildString {
append("<!doctype html><meta charset=\"utf-8\"><table><thead><tr>")
fields(settings).forEach { append("<th>${escape(header(it))}</th>") }
append("</tr></thead><tbody>")
}
fun htmlRow(
entry: NotificationLogEntry,
settings: LogViewSettings,
imagePath: String? = null,
context: Context? = null,
): String = buildString {
append("<tr>")
fields(settings).forEach { field -> append("<td>${htmlValue(field, entry, settings, imagePath, context)}</td>") }
append("</tr>")
}
fun htmlEnd(): String = "</tbody></table>"
fun html(
entries: List<NotificationLogEntry>,
settings: LogViewSettings,
context: Context? = null,
imagePath: (NotificationLogEntry) -> String? = { null },
): String = buildString {
append(htmlStart(settings))
entries.sortedByDescending { it.recordedAtEpochMillis }.forEach { entry ->
append(htmlRow(entry, settings, imagePath(entry), context))
}
append(htmlEnd())
}
private fun fields(settings: LogViewSettings): List<LogField> = settings.order.filter { it in settings.visibleFields }
private fun header(field: LogField) = field.name.lowercase().replace('_', ' ')
private fun htmlValue(field: LogField, entry: NotificationLogEntry, settings: LogViewSettings, imagePath: String?, context: Context?): String {
val value = when (field) {
LogField.TIMESTAMP -> timestamp(context, entry.recordedAtEpochMillis, settings, entry.eventTimeZoneId)
LogField.APP_NAME -> entry.appName
LogField.PACKAGE_NAME -> entry.packageName
LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ')
LogField.CONTENTS -> entry.contents.orEmpty()
}
val escaped = escape(value)
return if (field == LogField.CONTENTS && imagePath != null) {
escaped.replace("[image]", "<img src=\"${escape(imagePath)}\" alt=\"[image]\">")
} else {
escaped
}
}
private fun timestamp(context: Context?, value: Long, settings: LogViewSettings, eventTimeZoneId: String?): String =
TimestampFormatter.format(context, value, settings, eventTimeZoneId)
private fun csvLine(values: List<String>): String = values.joinToString(",") { csvValue(it) }
private fun csvValue(value: String) = "\"${value.replace("\"", "\"\"")}\""
private fun escape(value: String) = value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
}
@@ -1,4 +1,4 @@
package se.ajpanton.notificationlog.model package se.ajpanton.notificationsmaster.model
import java.util.UUID import java.util.UUID
@@ -41,7 +41,6 @@ enum class NotificationAction {
GROUP_SUMMARY_CANCELLED, GROUP_SUMMARY_CANCELLED,
GROUP_OPTIMIZED, GROUP_OPTIMIZED,
UNAUTOBUNDLED, UNAUTOBUNDLED,
BUNDLE_DISMISSED,
CLEAR_DATA, CLEAR_DATA,
ASSISTANT_CANCELLED, ASSISTANT_CANCELLED,
LOCKDOWN, LOCKDOWN,
@@ -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()
}
@@ -0,0 +1,45 @@
package se.ajpanton.notificationsmaster.settings
import android.content.Context
import androidx.core.content.edit
data class AppFilterSettings(
val mode: AppRuleMode = AppRuleMode.BLACKLIST,
val selectedPackages: Set<String> = emptySet(),
val onlySeenApps: Boolean = false,
val seenAppsFirst: Boolean = true,
)
class AppFilterSettingsStore(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
fun load(): AppFilterSettings = AppFilterSettings(
mode = AppRuleMode.valueOf(preferences.getString(MODE, AppRuleMode.BLACKLIST.name)!!),
selectedPackages = preferences.getStringSet(SELECTED_PACKAGES, emptySet())?.toSet() ?: emptySet(),
onlySeenApps = preferences.getBoolean(ONLY_SEEN_APPS, false),
seenAppsFirst = preferences.getBoolean(SEEN_APPS_FIRST, true),
)
fun save(settings: AppFilterSettings) = preferences.edit {
putString(MODE, settings.mode.name)
putStringSet(SELECTED_PACKAGES, settings.selectedPackages)
putBoolean(ONLY_SEEN_APPS, settings.onlySeenApps)
putBoolean(SEEN_APPS_FIRST, settings.seenAppsFirst)
}
fun removeUninstalledPackages(installedPackages: Set<String>) {
val settings = load()
val retainedPackages = settings.selectedPackages.intersect(installedPackages)
if (retainedPackages != settings.selectedPackages) {
save(settings.copy(selectedPackages = retainedPackages))
}
}
private companion object {
const val FILE_NAME = "app-filter-settings"
const val MODE = "mode"
const val SELECTED_PACKAGES = "selected_packages"
const val ONLY_SEEN_APPS = "only_seen_apps"
const val SEEN_APPS_FIRST = "seen_apps_first"
}
}
@@ -0,0 +1,47 @@
package se.ajpanton.notificationsmaster.settings
data class ListedApp(
val label: String,
val packageName: String,
val seen: Boolean,
val selected: Boolean,
val hasEventOverride: Boolean = false,
) {
val edited: Boolean get() = selected || hasEventOverride
}
sealed interface AppListItem {
data class App(val value: ListedApp) : AppListItem
data class SectionTitle(val value: String) : AppListItem
data object Separator : AppListItem
}
object AppListOrdering {
fun items(
allApps: List<ListedApp>,
onlySeen: Boolean,
seenAppsFirst: Boolean,
): List<AppListItem> {
val apps = allApps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label })
val shown = if (onlySeen) apps.filter { it.seen || it.edited } else apps
val seen = shown.filter { it.seen }
val editedUnseen = shown.filter { !it.seen }
if (!seenAppsFirst || seen.isEmpty()) return shown.map(AppListItem::App)
return buildList {
if (!onlySeen) add(AppListItem.SectionTitle("Seen apps"))
if (onlySeen && editedUnseen.isNotEmpty()) add(AppListItem.SectionTitle("Seen apps"))
seen.forEach { add(AppListItem.App(it)) }
if (onlySeen) {
if (editedUnseen.isNotEmpty()) {
add(AppListItem.Separator)
add(AppListItem.SectionTitle("Edited apps"))
editedUnseen.forEach { add(AppListItem.App(it)) }
}
} else {
add(AppListItem.Separator)
add(AppListItem.SectionTitle("All apps"))
// The full alphabetical list intentionally repeats seen apps after its quick-access group.
shown.forEach { add(AppListItem.App(it)) }
}
}
}
}
@@ -1,10 +1,11 @@
package se.ajpanton.notificationlog.settings package se.ajpanton.notificationsmaster.settings
import android.content.Context import android.content.Context
import androidx.core.content.edit
class AppLockStore(context: Context) { class AppLockStore(context: Context) {
private val prefs = context.getSharedPreferences("app-lock", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("app-lock", Context.MODE_PRIVATE)
var enabled: Boolean var enabled: Boolean
get() = prefs.getBoolean("enabled", false) get() = prefs.getBoolean("enabled", false)
set(value) = prefs.edit().putBoolean("enabled", value).apply() set(value) = prefs.edit { putBoolean("enabled", value) }
} }
@@ -0,0 +1,18 @@
package se.ajpanton.notificationsmaster.settings
import android.content.Context
import androidx.core.content.edit
/** Global capture choices that are independent of a particular event type. */
class CaptureSettingsStore(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
var logGroupSummaries: Boolean
get() = preferences.getBoolean(LOG_GROUP_SUMMARIES, false)
set(value) = preferences.edit { putBoolean(LOG_GROUP_SUMMARIES, value) }
private companion object {
const val FILE_NAME = "capture-settings"
const val LOG_GROUP_SUMMARIES = "log_group_summaries"
}
}
@@ -0,0 +1,28 @@
package se.ajpanton.notificationsmaster.settings
import android.content.Context
import androidx.core.content.edit
class LogAppFilterStore(context: Context) {
private val prefs = context.getSharedPreferences("log-app-filter", Context.MODE_PRIVATE)
fun selectedPackages(): Set<String> = prefs.getStringSet("packages", emptySet()).orEmpty().toSet()
fun selectedApps(): List<App> = selectedPackages().map { packageName ->
App(prefs.getString("name:$packageName", packageName).orEmpty(), packageName)
}
fun save(apps: Collection<App>) = prefs.edit {
val packages = apps.mapTo(mutableSetOf()) { it.packageName }
prefs.all.keys.filter { it.startsWith("name:") && it.removePrefix("name:") !in packages }.forEach(::remove)
putStringSet("packages", packages)
apps.forEach { putString("name:${it.packageName}", it.name) }
}
fun clear() = prefs.edit {
remove("packages")
prefs.all.keys.filter { it.startsWith("name:") }.forEach(::remove)
}
data class App(val name: String, val packageName: String)
}
@@ -0,0 +1,16 @@
package se.ajpanton.notificationsmaster.settings
enum class LogField { TIMESTAMP, APP_NAME, PACKAGE_NAME, ACTION, CONTENTS }
enum class TimestampZone { EVENT_LOCAL, LOCAL_NOW, UTC }
enum class TimestampDateFormat { SYSTEM_DEFAULT, YEAR_MONTH_DAY, DAY_MONTH_YEAR, MONTH_DAY_YEAR }
enum class TimestampClockFormat { SYSTEM_DEFAULT, HOUR_24, HOUR_12 }
enum class DisplayEvent { APPEARING, DISAPPEARING, EDITS }
data class LogViewSettings(
val visibleFields: Set<LogField> = setOf(LogField.TIMESTAMP, LogField.APP_NAME, LogField.ACTION, LogField.CONTENTS),
val order: List<LogField> = LogField.entries,
val timestampZone: TimestampZone = TimestampZone.EVENT_LOCAL,
val timestampDateFormat: TimestampDateFormat = TimestampDateFormat.SYSTEM_DEFAULT,
val timestampClockFormat: TimestampClockFormat = TimestampClockFormat.SYSTEM_DEFAULT,
val visibleEvents: Set<DisplayEvent> = DisplayEvent.entries.toSet(),
)

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