Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32d0935389 | ||
|
|
eef433ce88 | ||
|
|
1d2144e72c | ||
|
|
78bf4bde8c | ||
|
|
99f999d4e0 | ||
|
|
ed5f0c67db | ||
|
|
b1458b13e3 | ||
|
|
eafc544da8 | ||
|
|
dc2d50868f | ||
|
|
3289d62ca0 | ||
|
|
dc11e605d1 | ||
|
|
a7c9a2a22f | ||
|
|
0a8405e171 | ||
|
|
30753f1b6a | ||
|
|
08f8764c77 | ||
|
|
55de8f7aa9 | ||
|
|
fe99f789ed | ||
|
|
2bf35884dc | ||
|
|
4ff37e6699 | ||
|
|
192bdb5381 | ||
|
|
0a0e7c7649 | ||
|
|
7331ac8f01 | ||
|
|
e814a1aab3 | ||
|
|
738ae25f1d | ||
|
|
ce4adef117 | ||
|
|
b7d6bb6d63 | ||
|
|
626776886a | ||
|
|
179dc3a1db | ||
|
|
c8d7e5005e | ||
|
|
568b9a9549 | ||
|
|
ae1bf8e62b | ||
|
|
c14b2c5e61 | ||
|
|
c831e9adfb | ||
|
|
cab90de5af | ||
|
|
47fbda8a5e | ||
|
|
4fcbffd4a5 | ||
|
|
38a099f5d3 | ||
|
|
a3063d0c9a | ||
|
|
44357bf047 | ||
|
|
de984fb36c | ||
|
|
e80486df65 | ||
|
|
b06434b7a0 | ||
|
|
e2fb9e0d1c | ||
|
|
4a7981669b | ||
|
|
4e0c5d5298 | ||
|
|
a17d5651c8 | ||
|
|
8b66456d8c | ||
|
|
bd727eaec0 | ||
|
|
c9d189ac97 | ||
|
|
243bf62e67 | ||
|
|
dbe6335978 | ||
|
|
68771d5836 | ||
|
|
7166cfe18b | ||
|
|
77a725ae30 | ||
|
|
7fac006ad3 | ||
|
|
ba09bed24f | ||
|
|
7f61f11cbf | ||
|
|
ce49f21739 | ||
|
|
2f546afc89 | ||
|
|
40a9a1b042 | ||
|
|
1b21ff6751 | ||
|
|
6aab879dbd | ||
|
|
0089f839e3 | ||
|
|
de09b283ad | ||
|
|
594931e51d | ||
|
|
c209ef24bb | ||
|
|
720db4b7d3 | ||
|
|
ae409612b4 | ||
|
|
5e0662a233 | ||
|
|
9c9ddb6350 | ||
|
|
af3673c2e8 | ||
|
|
572660a967 | ||
|
|
9832b80e64 | ||
|
|
96bd5c4d17 | ||
|
|
d85f9050cc | ||
|
|
653080090b | ||
|
|
6eaf2a8784 | ||
|
|
5b8de63aac |
@@ -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
|
||||||
|
|
||||||
@@ -30,14 +30,59 @@ unencrypted file at the location you choose. Android may withhold sensitive or
|
|||||||
custom notification content; this app records only what Android makes available
|
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 34–36).
|
Notifications Master supports Android 14 through Android 16 (API levels 34–36).
|
||||||
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:
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -91,6 +91,13 @@ dependencies {
|
|||||||
implementation(libs.recyclerview)
|
implementation(libs.recyclerview)
|
||||||
implementation(libs.swiperefreshlayout)
|
implementation(libs.swiperefreshlayout)
|
||||||
implementation(libs.lifecycle.runtime.ktx)
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
+43
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+40
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -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" }
|
||||||
|
}
|
||||||
+54
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,14 @@
|
|||||||
<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">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY" />
|
||||||
|
|
||||||
|
<permission
|
||||||
|
android:name="se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY"
|
||||||
|
android:protectionLevel="signature" />
|
||||||
|
|
||||||
<!-- Required for the all-installed-apps filter; this app is not distributed through Google Play. -->
|
<!-- Required for the all-installed-apps filter; this app is not distributed through Google Play. -->
|
||||||
<uses-permission
|
<uses-permission
|
||||||
@@ -9,14 +17,15 @@
|
|||||||
tools:ignore="QueryAllPackagesPermission" />
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".NotificationLogApplication"
|
android:name=".NotificationsMasterApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@drawable/ic_launcher"
|
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"
|
||||||
@@ -36,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,34 +0,0 @@
|
|||||||
package se.ajpanton.notificationlog
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import se.ajpanton.notificationlog.databinding.FragmentPageBinding
|
|
||||||
|
|
||||||
class PageFragment : Fragment(R.layout.fragment_page) {
|
|
||||||
private var binding: FragmentPageBinding? = null
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
binding = FragmentPageBinding.bind(view)
|
|
||||||
binding?.pageTitle?.setText(requireArguments().getInt(ARG_TITLE))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
binding = null
|
|
||||||
super.onDestroyView()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPageTitleVisible(visible: Boolean) {
|
|
||||||
binding?.pageTitle?.visibility = if (visible) View.VISIBLE else View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val ARG_TITLE = "title"
|
|
||||||
|
|
||||||
fun newInstance(@StringRes titleRes: Int) = PageFragment().apply {
|
|
||||||
arguments = Bundle().apply { putInt(ARG_TITLE, titleRes) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package se.ajpanton.notificationlog.capture
|
|
||||||
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
/** Process/boot-lifetime record of packages seen by the listener. */
|
|
||||||
object SeenApps {
|
|
||||||
private val packages = ConcurrentHashMap.newKeySet<String>()
|
|
||||||
fun markSeen(packageName: String) { packages += packageName }
|
|
||||||
fun snapshot(): Set<String> = packages.toSet()
|
|
||||||
}
|
|
||||||
-41
@@ -1,41 +0,0 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
|
||||||
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.service.notification.NotificationListenerService
|
|
||||||
import android.util.Log
|
|
||||||
import se.ajpanton.notificationlog.capture.NotificationCaptureService
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notification listeners are re-bound by Android after boot when their component
|
|
||||||
* is enabled and the user granted notification access. Disabling the component
|
|
||||||
* is therefore both the no-work battery mode and the no-start-on-boot mode.
|
|
||||||
*/
|
|
||||||
internal object NotificationListenerComponentController {
|
|
||||||
fun update(context: Context, rules: Map<LoggingType, LoggingRule>, hasEnabledEventOverride: Boolean = false) {
|
|
||||||
val applicationContext = context.applicationContext
|
|
||||||
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
|
|
||||||
val packageManager = applicationContext.packageManager
|
|
||||||
val desired = if (NotificationListenerPolicy.shouldRun(rules, hasEnabledEventOverride)) {
|
|
||||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
|
||||||
} else {
|
|
||||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
|
||||||
}
|
|
||||||
if (packageManager.getComponentEnabledSetting(component) != desired) {
|
|
||||||
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
|
|
||||||
}
|
|
||||||
if (desired == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
|
||||||
try {
|
|
||||||
// Data clearing and OEM listener management can leave an enabled
|
|
||||||
// component without a live binding. Ask Android to reconnect on
|
|
||||||
// every enabled-rule synchronization, not just a state change.
|
|
||||||
NotificationListenerService.requestRebind(component)
|
|
||||||
} catch (error: SecurityException) {
|
|
||||||
Log.w(TAG, "Notification access is not granted yet; Android will bind after the user grants it", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val TAG = "NotificationListenerControl"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.CheckBox
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.Spinner
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.core.widget.doAfterTextChanged
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertConfiguration
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertOutcome
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertRule
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertRuleDefinition
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertSource
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertTextField
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertTextMatcher
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertTextMode
|
||||||
|
|
||||||
|
object AlertRuleDialog {
|
||||||
|
fun show(
|
||||||
|
context: Context,
|
||||||
|
configuration: AlertConfiguration,
|
||||||
|
existing: AlertRule?,
|
||||||
|
apps: List<InstalledApp>? = null,
|
||||||
|
onSave: (List<String>?, AlertRuleDefinition) -> Unit,
|
||||||
|
onDelete: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val form = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL; setPadding(dp(context, 24), 0, dp(context, 24), 0) }
|
||||||
|
val selectedApps = linkedSetOf<String>()
|
||||||
|
val app = apps?.let { choices ->
|
||||||
|
Button(context).apply {
|
||||||
|
text = "Choose apps"
|
||||||
|
setOnClickListener { showAppChooser(context, choices, selectedApps, this) }
|
||||||
|
}.also(form::addView)
|
||||||
|
}
|
||||||
|
val profiles = configuration.profiles
|
||||||
|
val profile = Spinner(context).apply {
|
||||||
|
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, profiles.map { it.name })
|
||||||
|
existing?.let { rule -> setSelection(profiles.indexOfFirst { it.id == rule.profileId }.coerceAtLeast(0)) }
|
||||||
|
}
|
||||||
|
val outcomes = listOf(
|
||||||
|
AlertOutcome.PLAY_PROFILE to "Play profile",
|
||||||
|
AlertOutcome.SILENCE_ORIGINAL to "Silence original alert",
|
||||||
|
AlertOutcome.PASS_THROUGH to "Keep original alert",
|
||||||
|
)
|
||||||
|
val outcome = Spinner(context).apply {
|
||||||
|
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, outcomes.map { it.second })
|
||||||
|
setSelection(outcomes.indexOfFirst { it.first == existing?.outcome }.coerceAtLeast(0))
|
||||||
|
}
|
||||||
|
val post = CheckBox(context).apply { text = "Appearing"; isChecked = existing == null || AlertSource.NOTIFICATION_POST in existing.sources }
|
||||||
|
val update = CheckBox(context).apply { text = "Edits"; isChecked = existing == null || AlertSource.NOTIFICATION_UPDATE in existing.sources }
|
||||||
|
val pattern = EditText(context).apply { hint = "Text to match (optional)"; setText(existing?.matcher?.value) }
|
||||||
|
val regex = SwitchMaterial(context).apply { text = "Use regular expression"; isChecked = existing?.matcher?.mode == AlertTextMode.REGEX }
|
||||||
|
val protected = SwitchMaterial(context).apply { text = "Play to completion"; isChecked = existing?.playToCompletion == true }
|
||||||
|
val dnd = SwitchMaterial(context).apply {
|
||||||
|
text = "Allow during DND (requires system support)"
|
||||||
|
isChecked = existing?.allowDuringDnd == true
|
||||||
|
}
|
||||||
|
listOf(outcome, profile, post, update, pattern, regex, protected, dnd).forEach(form::addView)
|
||||||
|
form.addView(android.widget.TextView(context).apply {
|
||||||
|
text = "Android blocks direct custom alerts during Do Not Disturb. This setting is retained for optional system support, but does not bypass Do Not Disturb in the normal app."
|
||||||
|
textSize = 14f
|
||||||
|
})
|
||||||
|
fun updateOutcomeFields() {
|
||||||
|
val playsProfile = outcomes[outcome.selectedItemPosition].first == AlertOutcome.PLAY_PROFILE
|
||||||
|
profile.isEnabled = playsProfile
|
||||||
|
protected.isEnabled = playsProfile
|
||||||
|
dnd.isEnabled = playsProfile
|
||||||
|
}
|
||||||
|
outcome.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit
|
||||||
|
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) = updateOutcomeFields()
|
||||||
|
}
|
||||||
|
updateOutcomeFields()
|
||||||
|
val dialog = MaterialAlertDialogBuilder(context).setTitle(if (existing == null) "Add rule" else "Edit rule")
|
||||||
|
.setView(form).setNegativeButton("Cancel", null).setPositiveButton("Save", null)
|
||||||
|
.apply { if (onDelete != null) setNeutralButton("Delete", null) }.show()
|
||||||
|
dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).setOnClickListener {
|
||||||
|
val sources = buildSet {
|
||||||
|
if (post.isChecked) add(AlertSource.NOTIFICATION_POST)
|
||||||
|
if (update.isChecked) add(AlertSource.NOTIFICATION_UPDATE)
|
||||||
|
}
|
||||||
|
if (sources.isEmpty()) { post.error = "Choose an event"; return@setOnClickListener }
|
||||||
|
if (apps != null && selectedApps.isEmpty()) { app?.error = "Choose at least one app"; return@setOnClickListener }
|
||||||
|
val value = pattern.text.toString().trim()
|
||||||
|
val matcher = runCatching {
|
||||||
|
AlertTextMatcher(
|
||||||
|
AlertTextField.ANY_TEXT,
|
||||||
|
if (value.isEmpty()) AlertTextMode.ANY else if (regex.isChecked) AlertTextMode.REGEX else AlertTextMode.CONTAINS,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
}.getOrElse {
|
||||||
|
pattern.error = "Invalid regular expression"
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
val selectedOutcome = outcomes[outcome.selectedItemPosition].first
|
||||||
|
val profileId = if (selectedOutcome == AlertOutcome.PLAY_PROFILE) {
|
||||||
|
profiles.getOrNull(profile.selectedItemPosition)?.id ?: run {
|
||||||
|
android.widget.Toast.makeText(context, "Create a profile first", android.widget.Toast.LENGTH_LONG).show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
val definition = AlertRuleDefinition(
|
||||||
|
sources,
|
||||||
|
matcher,
|
||||||
|
selectedOutcome,
|
||||||
|
profileId,
|
||||||
|
protected.isEnabled && protected.isChecked,
|
||||||
|
dnd.isEnabled && dnd.isChecked,
|
||||||
|
)
|
||||||
|
onSave(apps?.let { selectedApps.toList() }, definition)
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
if (onDelete != null) dialog.getButton(android.content.DialogInterface.BUTTON_NEUTRAL).setOnClickListener {
|
||||||
|
MaterialAlertDialogBuilder(context).setTitle("Delete rule?")
|
||||||
|
.setMessage("This cannot be undone.")
|
||||||
|
.setNegativeButton("Cancel", null)
|
||||||
|
.setPositiveButton("Delete") { _, _ -> onDelete(); dialog.dismiss() }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dp(context: Context, value: Int) = (value * context.resources.displayMetrics.density).toInt()
|
||||||
|
|
||||||
|
private fun showAppChooser(
|
||||||
|
context: Context,
|
||||||
|
apps: List<InstalledApp>,
|
||||||
|
selected: MutableSet<String>,
|
||||||
|
button: Button,
|
||||||
|
) {
|
||||||
|
val list = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
|
||||||
|
val search = EditText(context).apply { hint = "Filter apps" }
|
||||||
|
fun updateButton() {
|
||||||
|
button.text = when (selected.size) {
|
||||||
|
0 -> "Choose apps"
|
||||||
|
1 -> apps.first { it.packageName in selected }.name
|
||||||
|
else -> apps.first { it.packageName in selected }.name + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun showMatches(query: String) {
|
||||||
|
val terms = query.lowercase().split(Regex("\\s+")).filter(String::isNotBlank)
|
||||||
|
list.removeAllViews()
|
||||||
|
apps.filter { app ->
|
||||||
|
val searchable = "${app.name} ${app.packageName}".lowercase()
|
||||||
|
terms.all(searchable::contains)
|
||||||
|
}.forEach { app ->
|
||||||
|
val checkBox = CheckBox(context).apply { isChecked = app.packageName in selected }
|
||||||
|
list.addView(LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
isClickable = true
|
||||||
|
setPadding(0, dp(context, 4), 0, dp(context, 4))
|
||||||
|
addView(checkBox)
|
||||||
|
addView(LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
addView(TextView(context).apply { text = app.name; textSize = 16f; setTypeface(typeface, android.graphics.Typeface.BOLD) })
|
||||||
|
addView(TextView(context).apply { text = app.packageName; textSize = 12f; alpha = 0.72f })
|
||||||
|
})
|
||||||
|
setOnClickListener { checkBox.isChecked = !checkBox.isChecked }
|
||||||
|
})
|
||||||
|
checkBox.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) selected += app.packageName else selected -= app.packageName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
search.doAfterTextChanged { showMatches(it.toString()) }
|
||||||
|
showMatches("")
|
||||||
|
val content = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(dp(context, 24), 0, dp(context, 24), 0)
|
||||||
|
addView(search)
|
||||||
|
addView(ScrollView(context).apply { addView(list) }, LinearLayout.LayoutParams(-1, dp(context, 420)))
|
||||||
|
}
|
||||||
|
MaterialAlertDialogBuilder(context).setTitle("Apps this rule applies to")
|
||||||
|
.setView(content).setPositiveButton("Done") { _, _ -> updateButton() }.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertQueueOverflow
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings
|
||||||
|
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
|
||||||
|
import se.ajpanton.notificationsmaster.databinding.FragmentAlertsBinding
|
||||||
|
|
||||||
|
class AlertsFragment : Fragment(R.layout.fragment_alerts) {
|
||||||
|
private var binding: FragmentAlertsBinding? = null
|
||||||
|
private lateinit var store: AlertConfigurationStore
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
binding = FragmentAlertsBinding.bind(view)
|
||||||
|
store = AlertConfigurationStore(requireContext())
|
||||||
|
val configuration = store.load()
|
||||||
|
binding!!.enableAlerts.isChecked = configuration.enabled
|
||||||
|
binding!!.ignoreRoutineUpdates.isChecked = configuration.ignoreRoutineUpdates
|
||||||
|
binding!!.dropOldest.isChecked = configuration.queueSettings.overflow == AlertQueueOverflow.DROP_OLDEST
|
||||||
|
binding!!.queueLength.adapter = ArrayAdapter(
|
||||||
|
requireContext(),
|
||||||
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
|
(1..10).map(Int::toString),
|
||||||
|
)
|
||||||
|
binding!!.queueLength.setSelection(configuration.queueSettings.maximumLength - 1, false)
|
||||||
|
binding!!.notificationAccess.setOnClickListener {
|
||||||
|
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||||
|
}
|
||||||
|
bindChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
val context = requireContext()
|
||||||
|
val access = context.getSystemService(NotificationManager::class.java)
|
||||||
|
.isNotificationListenerAccessGranted(ComponentName(context, NotificationCaptureService::class.java))
|
||||||
|
binding?.notificationAccess?.apply {
|
||||||
|
text = if (access) "Notification access enabled" else "Enable notification access"
|
||||||
|
backgroundTintList = ColorStateList.valueOf(
|
||||||
|
ContextCompat.getColor(
|
||||||
|
context,
|
||||||
|
if (access) R.color.notification_access_granted else R.color.notification_access_missing,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
binding = null
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindChanges() {
|
||||||
|
binding!!.enableAlerts.setOnCheckedChangeListener { _, enabled ->
|
||||||
|
store.save(store.load().copy(enabled = enabled))
|
||||||
|
}
|
||||||
|
binding!!.ignoreRoutineUpdates.setOnCheckedChangeListener { _, ignore ->
|
||||||
|
store.save(store.load().copy(ignoreRoutineUpdates = ignore))
|
||||||
|
}
|
||||||
|
binding!!.dropOldest.setOnCheckedChangeListener { _, oldest ->
|
||||||
|
saveQueue(overflow = if (oldest) AlertQueueOverflow.DROP_OLDEST else AlertQueueOverflow.DROP_NEWEST)
|
||||||
|
}
|
||||||
|
binding!!.queueLength.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit
|
||||||
|
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||||
|
saveQueue(maximumLength = position + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveQueue(maximumLength: Int? = null, overflow: AlertQueueOverflow? = null) {
|
||||||
|
val current = store.load().queueSettings
|
||||||
|
store.save(
|
||||||
|
store.load().copy(
|
||||||
|
queueSettings = AlertQueueSettings(
|
||||||
|
maximumLength ?: current.maximumLength,
|
||||||
|
overflow ?: current.overflow,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertAppSettings
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertRule
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertRuleEditor
|
||||||
|
import se.ajpanton.notificationsmaster.databinding.FragmentAppRulesBinding
|
||||||
|
|
||||||
|
class AppRulesFragment : Fragment(R.layout.fragment_app_rules) {
|
||||||
|
private var binding: FragmentAppRulesBinding? = null
|
||||||
|
private lateinit var packageName: String
|
||||||
|
private lateinit var store: AlertConfigurationStore
|
||||||
|
private val adapter = RuleAdapter(::edit)
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
packageName = requireArguments().getString(ARG_PACKAGE)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
binding = FragmentAppRulesBinding.bind(view)
|
||||||
|
store = AlertConfigurationStore(requireContext())
|
||||||
|
binding!!.rules.adapter = adapter
|
||||||
|
binding!!.addRule.setOnClickListener { edit(null) }
|
||||||
|
binding!!.allowMultipleMatches.isChecked = store.load().appSettings.firstOrNull { it.packageName == packageName }?.allowMultipleMatches == true
|
||||||
|
binding!!.allowMultipleMatches.setOnCheckedChangeListener { _, enabled -> setAllowMultipleMatches(enabled) }
|
||||||
|
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
|
||||||
|
override fun onMove(recyclerView: RecyclerView, holder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
|
||||||
|
adapter.move(holder.bindingAdapterPosition, target.bindingAdapterPosition)
|
||||||
|
saveOrder()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
override fun onSwiped(holder: RecyclerView.ViewHolder, direction: Int) = Unit
|
||||||
|
}).attachToRecyclerView(binding!!.rules)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
activity?.title = InstalledApps.name(requireContext(), packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() { binding = null; super.onDestroyView() }
|
||||||
|
|
||||||
|
private fun refresh() = adapter.submit(store.load().rules.filter { it.packageName == packageName }.sortedBy { it.order })
|
||||||
|
|
||||||
|
private fun edit(existing: AlertRule?) {
|
||||||
|
val configuration = store.load()
|
||||||
|
AlertRuleDialog.show(requireContext(), configuration, existing,
|
||||||
|
onSave = { _, definition ->
|
||||||
|
store.save(if (existing == null) AlertRuleEditor.addForApps(configuration, listOf(packageName), definition) else AlertRuleEditor.updateForApp(configuration, existing.id, definition))
|
||||||
|
refresh()
|
||||||
|
},
|
||||||
|
onDelete = existing?.let { rule -> { store.save(AlertRuleEditor.remove(store.load(), setOf(rule.id))); refresh() } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveOrder() {
|
||||||
|
store.save(AlertRuleEditor.reorderForApp(store.load(), packageName, adapter.items.map { it.id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setAllowMultipleMatches(enabled: Boolean) {
|
||||||
|
val configuration = store.load()
|
||||||
|
val old = configuration.appSettings.firstOrNull { it.packageName == packageName }
|
||||||
|
val setting = (old ?: AlertAppSettings(packageName)).copy(allowMultipleMatches = enabled)
|
||||||
|
saveAppSettings(configuration, setting)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAppSettings(configuration: se.ajpanton.notificationsmaster.alerts.AlertConfiguration, setting: AlertAppSettings) {
|
||||||
|
store.save(configuration.copy(appSettings = configuration.appSettings.filterNot { it.packageName == packageName } +
|
||||||
|
if (setting.allowMultipleMatches) listOf(setting) else emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RuleAdapter(private val onClick: (AlertRule) -> Unit) : RecyclerView.Adapter<RuleAdapter.Holder>() {
|
||||||
|
var items = emptyList<AlertRule>(); private set
|
||||||
|
fun submit(rules: List<AlertRule>) { items = rules; notifyDataSetChanged() }
|
||||||
|
fun move(from: Int, to: Int) { items = items.toMutableList().apply { add(to, removeAt(from)) }; notifyItemMoved(from, to) }
|
||||||
|
override fun onCreateViewHolder(parent: android.view.ViewGroup, viewType: Int) = Holder(TextView(parent.context).apply {
|
||||||
|
layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
|
||||||
|
val padding = (16 * parent.resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(padding, padding, padding, padding); textSize = 16f; isClickable = true
|
||||||
|
})
|
||||||
|
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||||
|
val rule = items[position]
|
||||||
|
holder.text.text = rule.name ?: "Rule ${position + 1}" + "\n" + rule.sources.joinToString { it.label() } + rule.matcher.value.takeIf { it.isNotBlank() }?.let { " • contains $it" }.orEmpty()
|
||||||
|
holder.text.setOnClickListener { onClick(rule) }
|
||||||
|
}
|
||||||
|
override fun getItemCount() = items.size
|
||||||
|
class Holder(val text: TextView) : RecyclerView.ViewHolder(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_PACKAGE = "package"
|
||||||
|
fun newInstance(packageName: String) = AppRulesFragment().apply { arguments = bundleOf(ARG_PACKAGE to packageName) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.commit
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
import se.ajpanton.notificationsmaster.databinding.FragmentAppVisibilityBinding
|
||||||
|
import se.ajpanton.notificationsmaster.databinding.ItemVisibilityExceptionBinding
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.AppVisibilityPolicy
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.VisibilityExceptionRule
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) {
|
||||||
|
private var binding: FragmentAppVisibilityBinding? = null
|
||||||
|
private lateinit var store: VisibilityPolicyStore
|
||||||
|
private lateinit var packageName: String
|
||||||
|
private lateinit var adapter: ExceptionAdapter
|
||||||
|
private lateinit var touchHelper: ItemTouchHelper
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
binding = FragmentAppVisibilityBinding.bind(view)
|
||||||
|
store = VisibilityPolicyStore(requireContext())
|
||||||
|
packageName = requireArguments().getString(ARG_PACKAGE)!!
|
||||||
|
binding!!.appName.text = InstalledApps.name(requireContext(), packageName)
|
||||||
|
binding!!.packageName.text = packageName
|
||||||
|
binding!!.returnToList.setOnClickListener(::returnToList)
|
||||||
|
|
||||||
|
val icon = requireContext().packageManager.getApplicationIcon(packageName)
|
||||||
|
adapter = ExceptionAdapter(icon, ::editException, ::deleteException, ::saveExceptions) {
|
||||||
|
touchHelper.startDrag(it)
|
||||||
|
}
|
||||||
|
binding!!.exceptions.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
binding!!.exceptions.adapter = adapter
|
||||||
|
touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) {
|
||||||
|
override fun isLongPressDragEnabled() = false
|
||||||
|
|
||||||
|
override fun onMove(
|
||||||
|
list: RecyclerView,
|
||||||
|
from: RecyclerView.ViewHolder,
|
||||||
|
to: RecyclerView.ViewHolder,
|
||||||
|
): Boolean {
|
||||||
|
adapter.move(from.bindingAdapterPosition, to.bindingAdapterPosition)
|
||||||
|
saveExceptions(adapter.rules())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSwiped(holder: RecyclerView.ViewHolder, direction: Int) = Unit
|
||||||
|
}).also { it.attachToRecyclerView(binding!!.exceptions) }
|
||||||
|
binding!!.addException.setOnClickListener { showExceptionDialog(null) }
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
activity?.title = getString(R.string.page_notification_visibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
binding = null
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun returnToList(@Suppress("UNUSED_PARAMETER") view: View) {
|
||||||
|
if (!parentFragmentManager.popBackStackImmediate()) {
|
||||||
|
parentFragmentManager.commit { replace(R.id.content_frame, NotificationVisibilityFragment()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bind() {
|
||||||
|
val policy = appPolicy()
|
||||||
|
bindSurface(binding!!.showAod, NotificationSurface.AOD, policy)
|
||||||
|
bindSurface(binding!!.showLockscreen, NotificationSurface.LOCKSCREEN_COLLAPSED, policy)
|
||||||
|
bindSurface(binding!!.showUnlocked, NotificationSurface.UNLOCKED_STATUSBAR, policy)
|
||||||
|
bindSurface(binding!!.showDrawer, NotificationSurface.DRAWER, policy)
|
||||||
|
adapter.submit(policy.exceptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindSurface(toggle: SwitchMaterial, surface: NotificationSurface, policy: AppVisibilityPolicy) {
|
||||||
|
toggle.setOnCheckedChangeListener(null)
|
||||||
|
toggle.isChecked = surface !in policy.blockedSurfaces
|
||||||
|
toggle.setOnCheckedChangeListener { _, visible ->
|
||||||
|
saveApp {
|
||||||
|
copy(blockedSurfaces = blockedSurfaces.toMutableSet().apply {
|
||||||
|
if (visible) remove(surface) else add(surface)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appPolicy() = store.load().apps.firstOrNull { it.packageName == packageName }
|
||||||
|
?: AppVisibilityPolicy(packageName)
|
||||||
|
|
||||||
|
private fun saveApp(change: AppVisibilityPolicy.() -> AppVisibilityPolicy) {
|
||||||
|
store.update { policy ->
|
||||||
|
val updated = (policy.apps.firstOrNull { it.packageName == packageName }
|
||||||
|
?: AppVisibilityPolicy(packageName)).change()
|
||||||
|
val apps = policy.apps.filterNot { it.packageName == packageName }.toMutableList()
|
||||||
|
if (updated.blockedSurfaces.isNotEmpty() || updated.exceptions.isNotEmpty()) apps += updated
|
||||||
|
policy.copy(apps = apps.sortedBy { it.packageName })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveExceptions(rules: List<VisibilityExceptionRule>) {
|
||||||
|
saveApp { copy(exceptions = rules) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun editException(position: Int) = showExceptionDialog(position)
|
||||||
|
|
||||||
|
private fun deleteException(position: Int) {
|
||||||
|
adapter.submit(adapter.rules().toMutableList().apply { removeAt(position) })
|
||||||
|
saveExceptions(adapter.rules())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showExceptionDialog(position: Int?) {
|
||||||
|
val existing = position?.let { adapter.rules().getOrNull(it) }
|
||||||
|
val pattern = EditText(requireContext()).apply {
|
||||||
|
hint = "Regular expression"
|
||||||
|
setText(existing?.pattern)
|
||||||
|
maxLines = 4
|
||||||
|
setPadding(dp(24), paddingTop, dp(24), paddingBottom)
|
||||||
|
}
|
||||||
|
val dialog = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(if (existing == null) "Add text exception" else "Edit text exception")
|
||||||
|
.setView(pattern)
|
||||||
|
.setNegativeButton("Cancel", null)
|
||||||
|
.setPositiveButton("Save", null)
|
||||||
|
.create()
|
||||||
|
dialog.setOnShowListener {
|
||||||
|
dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||||
|
val rule = VisibilityExceptionRule(
|
||||||
|
pattern.text.toString(),
|
||||||
|
existing?.blockedSurfaces ?: appPolicy().blockedSurfaces,
|
||||||
|
)
|
||||||
|
if (!rule.hasValidPattern()) {
|
||||||
|
pattern.error = "Enter a valid, non-empty regular expression"
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
val rules = adapter.rules().toMutableList().apply {
|
||||||
|
if (position == null) add(rule) else set(position, rule)
|
||||||
|
}
|
||||||
|
adapter.submit(rules)
|
||||||
|
saveExceptions(rules)
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
|
||||||
|
|
||||||
|
private class ExceptionAdapter(
|
||||||
|
private val appIcon: Drawable,
|
||||||
|
private val edit: (Int) -> Unit,
|
||||||
|
private val delete: (Int) -> Unit,
|
||||||
|
private val changed: (List<VisibilityExceptionRule>) -> Unit,
|
||||||
|
private val startDrag: (ExceptionHolder) -> Unit,
|
||||||
|
) : RecyclerView.Adapter<ExceptionHolder>() {
|
||||||
|
private val items = mutableListOf<VisibilityExceptionRule>()
|
||||||
|
|
||||||
|
fun submit(rules: List<VisibilityExceptionRule>) {
|
||||||
|
items.clear()
|
||||||
|
items.addAll(rules)
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun move(from: Int, to: Int) {
|
||||||
|
Collections.swap(items, from, to)
|
||||||
|
notifyItemMoved(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rules() = items.toList()
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ExceptionHolder(
|
||||||
|
ItemVisibilityExceptionBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ExceptionHolder, position: Int) {
|
||||||
|
val binding = holder.binding
|
||||||
|
val rule = items[position]
|
||||||
|
binding.pattern.text = rule.pattern
|
||||||
|
bindSurface(binding.aodIcon, binding.aodCross, rule, NotificationSurface.AOD)
|
||||||
|
bindSurface(
|
||||||
|
binding.lockscreenIcon,
|
||||||
|
binding.lockscreenCross,
|
||||||
|
rule,
|
||||||
|
NotificationSurface.LOCKSCREEN_COLLAPSED,
|
||||||
|
)
|
||||||
|
bindSurface(
|
||||||
|
binding.unlockedIcon,
|
||||||
|
binding.unlockedCross,
|
||||||
|
rule,
|
||||||
|
NotificationSurface.UNLOCKED_STATUSBAR,
|
||||||
|
)
|
||||||
|
bindSurface(binding.drawerIcon, binding.drawerCross, rule, NotificationSurface.DRAWER)
|
||||||
|
binding.aodCell.setOnClickListener { toggle(holder, NotificationSurface.AOD) }
|
||||||
|
binding.lockscreenCell.setOnClickListener {
|
||||||
|
toggle(holder, NotificationSurface.LOCKSCREEN_COLLAPSED)
|
||||||
|
}
|
||||||
|
binding.unlockedCell.setOnClickListener {
|
||||||
|
toggle(holder, NotificationSurface.UNLOCKED_STATUSBAR)
|
||||||
|
}
|
||||||
|
binding.drawerCell.setOnClickListener { toggle(holder, NotificationSurface.DRAWER) }
|
||||||
|
binding.edit.setOnClickListener { holder.positionOrNull()?.let(edit) }
|
||||||
|
binding.delete.setOnClickListener { holder.positionOrNull()?.let(delete) }
|
||||||
|
binding.dragHandle.setOnTouchListener { _, event ->
|
||||||
|
if (event.actionMasked == MotionEvent.ACTION_DOWN) startDrag(holder)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = items.size
|
||||||
|
|
||||||
|
private fun toggle(holder: ExceptionHolder, surface: NotificationSurface) {
|
||||||
|
val position = holder.positionOrNull() ?: return
|
||||||
|
val current = items[position]
|
||||||
|
items[position] = current.copy(blockedSurfaces = current.blockedSurfaces.toMutableSet().apply {
|
||||||
|
if (surface in current.blockedSurfaces) remove(surface) else add(surface)
|
||||||
|
})
|
||||||
|
notifyItemChanged(position)
|
||||||
|
changed(rules())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindSurface(
|
||||||
|
icon: ImageView,
|
||||||
|
cross: TextView,
|
||||||
|
rule: VisibilityExceptionRule,
|
||||||
|
surface: NotificationSurface,
|
||||||
|
) {
|
||||||
|
icon.setImageDrawable(appIcon.constantState?.newDrawable()?.mutate() ?: appIcon)
|
||||||
|
val hidden = surface in rule.blockedSurfaces
|
||||||
|
icon.alpha = if (hidden) 0.25f else 1f
|
||||||
|
cross.visibility = if (hidden) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ExceptionHolder.positionOrNull() =
|
||||||
|
bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ExceptionHolder(val binding: ItemVisibilityExceptionBinding) :
|
||||||
|
RecyclerView.ViewHolder(binding.root)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_PACKAGE = "package"
|
||||||
|
private const val UP = ItemTouchHelper.UP
|
||||||
|
private const val DOWN = ItemTouchHelper.DOWN
|
||||||
|
|
||||||
|
fun newInstance(packageName: String) = AppVisibilityFragment().apply {
|
||||||
|
arguments = Bundle().apply { putString(ARG_PACKAGE, packageName) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Button
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.commit
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
||||||
|
import se.ajpanton.notificationsmaster.databinding.FragmentAppsBinding
|
||||||
|
|
||||||
|
class AppsFragment : Fragment(R.layout.fragment_apps) {
|
||||||
|
private var binding: FragmentAppsBinding? = null
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
binding = FragmentAppsBinding.bind(view)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
activity?.title = getString(R.string.page_apps)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() { binding = null; super.onDestroyView() }
|
||||||
|
|
||||||
|
private fun refresh() {
|
||||||
|
val container = binding?.apps ?: return
|
||||||
|
val context = requireContext()
|
||||||
|
val configuration = AlertConfigurationStore(context).load()
|
||||||
|
val grouped = configuration.rules.groupBy { it.packageName }
|
||||||
|
val packages = grouped.keys + configuration.appSettings.map { it.packageName }
|
||||||
|
container.removeAllViews()
|
||||||
|
packages.distinct().sortedBy { InstalledApps.name(context, it).lowercase() }.forEach { packageName ->
|
||||||
|
container.addView(Button(context).apply {
|
||||||
|
val count = grouped[packageName].orEmpty().size
|
||||||
|
text = InstalledApps.name(context, packageName) + "\n" + packageName + " • " + count + if (count == 1) " rule" else " rules"
|
||||||
|
isAllCaps = false
|
||||||
|
setOnClickListener { open(packageName) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open(packageName: String) = parentFragmentManager.commit {
|
||||||
|
replace(R.id.content_frame, AppRulesFragment.newInstance(packageName))
|
||||||
|
addToBackStack(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.SeekBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
import se.ajpanton.notificationsmaster.databinding.FragmentCardsLayoutBinding
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.CardsGridSettings
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicy
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
|
||||||
|
|
||||||
|
class CardsLayoutFragment : Fragment(R.layout.fragment_cards_layout) {
|
||||||
|
private var binding: FragmentCardsLayoutBinding? = null
|
||||||
|
private lateinit var store: VisibilityPolicyStore
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
binding = FragmentCardsLayoutBinding.bind(view)
|
||||||
|
store = VisibilityPolicyStore(requireContext())
|
||||||
|
val policy = store.load()
|
||||||
|
bind(
|
||||||
|
controls(lockscreen = true),
|
||||||
|
policy.lockscreenCards,
|
||||||
|
VisibilityPolicy::lockscreenCards,
|
||||||
|
) { current, settings -> current.copy(lockscreenCards = settings) }
|
||||||
|
bind(
|
||||||
|
controls(lockscreen = false),
|
||||||
|
policy.aodCards,
|
||||||
|
VisibilityPolicy::aodCards,
|
||||||
|
) { current, settings -> current.copy(aodCards = settings) }
|
||||||
|
if (!DeviceCapabilities.isOneUi) {
|
||||||
|
binding!!.oneUiOnlyNotice.visibility = View.VISIBLE
|
||||||
|
binding!!.oneUiCardsControls.alpha = DISABLED_CONTROL_ALPHA
|
||||||
|
binding!!.oneUiCardsControls.disableRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
activity?.title = getString(R.string.page_cards_layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
binding = null
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bind(
|
||||||
|
controls: Controls,
|
||||||
|
initial: CardsGridSettings,
|
||||||
|
currentSettings: (VisibilityPolicy) -> CardsGridSettings,
|
||||||
|
update: (VisibilityPolicy, CardsGridSettings) -> VisibilityPolicy,
|
||||||
|
) {
|
||||||
|
controls.rows.bind(0..5, initial.maxRows) { value ->
|
||||||
|
save(currentSettings, update) { it.copy(maxRows = value) }
|
||||||
|
}
|
||||||
|
controls.icons.bind(1..15, initial.maxIconsPerRow) { value ->
|
||||||
|
save(currentSettings, update) { it.copy(maxIconsPerRow = value) }
|
||||||
|
}
|
||||||
|
controls.even.isChecked = initial.evenDistribution
|
||||||
|
controls.even.setOnCheckedChangeListener { _, checked ->
|
||||||
|
save(currentSettings, update) { it.copy(evenDistribution = checked) }
|
||||||
|
}
|
||||||
|
controls.limit.isChecked = initial.limitToScreenWidth
|
||||||
|
controls.limit.setOnCheckedChangeListener { _, checked ->
|
||||||
|
save(currentSettings, update) { it.copy(limitToScreenWidth = checked) }
|
||||||
|
}
|
||||||
|
controls.height.max = 90
|
||||||
|
controls.height.progress = initial.iconHeightPercent - 10
|
||||||
|
controls.heightValue.text = "${initial.iconHeightPercent}%"
|
||||||
|
controls.height.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||||
|
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
|
||||||
|
controls.heightValue.text = "${progress + 10}%"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartTrackingTouch(seekBar: SeekBar) = Unit
|
||||||
|
|
||||||
|
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||||
|
save(currentSettings, update) { it.copy(iconHeightPercent = seekBar.progress + 10) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(
|
||||||
|
currentSettings: (VisibilityPolicy) -> CardsGridSettings,
|
||||||
|
update: (VisibilityPolicy, CardsGridSettings) -> VisibilityPolicy,
|
||||||
|
change: (CardsGridSettings) -> CardsGridSettings,
|
||||||
|
) {
|
||||||
|
store.update { current ->
|
||||||
|
update(current, change(currentSettings(current)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NumberStepper.bind(range: IntRange, selected: Int, changed: (Int) -> Unit) {
|
||||||
|
var current = selected
|
||||||
|
fun show() {
|
||||||
|
value.text = current.toString()
|
||||||
|
minus.isEnabled = current > range.first
|
||||||
|
plus.isEnabled = current < range.last
|
||||||
|
}
|
||||||
|
fun step(amount: Int) {
|
||||||
|
val next = (current + amount).coerceIn(range)
|
||||||
|
if (next != current) {
|
||||||
|
current = next
|
||||||
|
show()
|
||||||
|
changed(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
minus.setOnClickListener { step(-1) }
|
||||||
|
plus.setOnClickListener { step(1) }
|
||||||
|
show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun controls(lockscreen: Boolean): Controls {
|
||||||
|
val b = binding!!
|
||||||
|
return if (lockscreen) b.lockscreenSettings.run {
|
||||||
|
Controls(
|
||||||
|
NumberStepper(lockscreenMaxRowsMinus, lockscreenMaxRows, lockscreenMaxRowsPlus),
|
||||||
|
NumberStepper(lockscreenMaxIconsMinus, lockscreenMaxIcons, lockscreenMaxIconsPlus),
|
||||||
|
lockscreenEvenDistribution,
|
||||||
|
lockscreenLimitWidth,
|
||||||
|
lockscreenIconHeight,
|
||||||
|
lockscreenIconHeightValue,
|
||||||
|
)
|
||||||
|
} else b.aodSettings.run {
|
||||||
|
Controls(
|
||||||
|
NumberStepper(aodMaxRowsMinus, aodMaxRows, aodMaxRowsPlus),
|
||||||
|
NumberStepper(aodMaxIconsMinus, aodMaxIcons, aodMaxIconsPlus),
|
||||||
|
aodEvenDistribution,
|
||||||
|
aodLimitWidth,
|
||||||
|
aodIconHeight,
|
||||||
|
aodIconHeightValue,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Controls(
|
||||||
|
val rows: NumberStepper,
|
||||||
|
val icons: NumberStepper,
|
||||||
|
val even: SwitchMaterial,
|
||||||
|
val limit: SwitchMaterial,
|
||||||
|
val height: SeekBar,
|
||||||
|
val heightValue: TextView,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class NumberStepper(val minus: Button, val value: TextView, val plus: Button)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
|
||||||
|
object DeviceCapabilities {
|
||||||
|
val isOneUi: Boolean
|
||||||
|
get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun View.disableRecursively() {
|
||||||
|
isEnabled = false
|
||||||
|
if (this is ViewGroup) repeat(childCount) { getChildAt(it).disableRecursively() }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val DISABLED_CONTROL_ALPHA = 0.45f
|
||||||
+13
-13
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
@@ -20,17 +20,17 @@ import androidx.recyclerview.widget.RecyclerView
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
import se.ajpanton.notificationlog.capture.SeenApps
|
import se.ajpanton.notificationsmaster.capture.SeenApps
|
||||||
import se.ajpanton.notificationlog.databinding.FragmentEventSettingsBinding
|
import se.ajpanton.notificationsmaster.databinding.FragmentEventSettingsBinding
|
||||||
import se.ajpanton.notificationlog.settings.AppListItem
|
import se.ajpanton.notificationsmaster.settings.AppListItem
|
||||||
import se.ajpanton.notificationlog.settings.AppListOrdering
|
import se.ajpanton.notificationsmaster.settings.AppListOrdering
|
||||||
import se.ajpanton.notificationlog.settings.AppRuleMode
|
import se.ajpanton.notificationsmaster.settings.AppRuleMode
|
||||||
import se.ajpanton.notificationlog.settings.ListedApp
|
import se.ajpanton.notificationsmaster.settings.ListedApp
|
||||||
import se.ajpanton.notificationlog.settings.AppFilterSettings
|
import se.ajpanton.notificationsmaster.settings.AppFilterSettings
|
||||||
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
|
import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
|
||||||
import se.ajpanton.notificationlog.settings.LoggingRuleStore
|
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
|
||||||
import se.ajpanton.notificationlog.settings.LoggingType
|
import se.ajpanton.notificationsmaster.settings.LoggingType
|
||||||
import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore
|
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -123,7 +123,7 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
|
|||||||
currentBinding.appListLoading.visibility = View.VISIBLE
|
currentBinding.appListLoading.visibility = View.VISIBLE
|
||||||
currentBinding.appListRefresh.isRefreshing = true
|
currentBinding.appListRefresh.isRefreshing = true
|
||||||
val rule = store.load()
|
val rule = store.load()
|
||||||
val seen = SeenApps.snapshot()
|
val seen = SeenApps.snapshot(requireContext())
|
||||||
val context = requireContext().applicationContext
|
val context = requireContext().applicationContext
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val apps = withContext(Dispatchers.IO) {
|
val apps = withContext(Dispatchers.IO) {
|
||||||
+5
-5
@@ -1,13 +1,13 @@
|
|||||||
package se.ajpanton.notificationlog
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
import se.ajpanton.notificationlog.databinding.FragmentFilterLoggingBinding
|
import se.ajpanton.notificationsmaster.databinding.FragmentFilterLoggingBinding
|
||||||
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
|
import se.ajpanton.notificationsmaster.settings.CaptureSettingsStore
|
||||||
import se.ajpanton.notificationlog.settings.LoggingRuleStore
|
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
|
||||||
import se.ajpanton.notificationlog.settings.LoggingType
|
import se.ajpanton.notificationsmaster.settings.LoggingType
|
||||||
|
|
||||||
class FilterLoggingFragment : Fragment(R.layout.fragment_filter_logging) {
|
class FilterLoggingFragment : Fragment(R.layout.fragment_filter_logging) {
|
||||||
private var binding: FragmentFilterLoggingBinding? = null
|
private var binding: FragmentFilterLoggingBinding? = null
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
@@ -19,7 +19,7 @@ import androidx.core.view.WindowInsetsCompat
|
|||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import se.ajpanton.notificationlog.data.EncryptedImageStore
|
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
data class InstalledApp(val name: String, val packageName: String) {
|
||||||
|
override fun toString() = "$name ($packageName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
object InstalledApps {
|
||||||
|
fun all(context: Context): List<InstalledApp> = context.packageManager.getInstalledApplications(0).map {
|
||||||
|
InstalledApp(context.packageManager.getApplicationLabel(it).toString(), it.packageName)
|
||||||
|
}.sortedBy { it.name.lowercase() }
|
||||||
|
|
||||||
|
fun name(context: Context, packageName: String): String = runCatching {
|
||||||
|
context.packageManager.getApplicationLabel(context.packageManager.getApplicationInfo(packageName, 0)).toString()
|
||||||
|
}.getOrDefault(packageName)
|
||||||
|
}
|
||||||
+9
-9
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -9,14 +9,14 @@ import android.widget.Spinner
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
import se.ajpanton.notificationlog.databinding.FragmentLogDisplayBinding
|
import se.ajpanton.notificationsmaster.databinding.FragmentLogDisplayBinding
|
||||||
import se.ajpanton.notificationlog.settings.DisplayEvent
|
import se.ajpanton.notificationsmaster.settings.DisplayEvent
|
||||||
import se.ajpanton.notificationlog.settings.LogField
|
import se.ajpanton.notificationsmaster.settings.LogField
|
||||||
import se.ajpanton.notificationlog.settings.LogViewSettings
|
import se.ajpanton.notificationsmaster.settings.LogViewSettings
|
||||||
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
|
import se.ajpanton.notificationsmaster.settings.LogViewSettingsStore
|
||||||
import se.ajpanton.notificationlog.settings.TimestampClockFormat
|
import se.ajpanton.notificationsmaster.settings.TimestampClockFormat
|
||||||
import se.ajpanton.notificationlog.settings.TimestampDateFormat
|
import se.ajpanton.notificationsmaster.settings.TimestampDateFormat
|
||||||
import se.ajpanton.notificationlog.settings.TimestampZone
|
import se.ajpanton.notificationsmaster.settings.TimestampZone
|
||||||
|
|
||||||
class LogDisplayFragment : Fragment(R.layout.fragment_log_display) {
|
class LogDisplayFragment : Fragment(R.layout.fragment_log_display) {
|
||||||
private var binding: FragmentLogDisplayBinding? = null
|
private var binding: FragmentLogDisplayBinding? = null
|
||||||
+114
-30
@@ -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
|
||||||
@@ -21,25 +23,28 @@ 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.AppLockStore
|
import se.ajpanton.notificationsmaster.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)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
showLockedOverlay(AppLockStore(this).enabled)
|
|
||||||
val isNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
val isNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||||
Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_YES
|
||||||
WindowInsetsControllerCompat(window, binding.root).isAppearanceLightNavigationBars = !isNightMode
|
WindowInsetsControllerCompat(window, binding.root).isAppearanceLightNavigationBars = !isNightMode
|
||||||
@@ -73,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()
|
||||||
}
|
}
|
||||||
@@ -85,33 +97,34 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (needsUnlock && AppLockStore(this).enabled) {
|
refreshLogProtection(requestUnlock = true)
|
||||||
showLockedOverlay(true)
|
|
||||||
requestUnlock()
|
|
||||||
} else {
|
|
||||||
showLockedOverlay(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
super.onStop()
|
super.onStop()
|
||||||
if (!isChangingConfigurations) {
|
if (!isChangingConfigurations && isProtectedLogPage()) {
|
||||||
needsUnlock = true
|
needsUnlock = true
|
||||||
if (AppLockStore(this).enabled) showLockedOverlay(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?) {
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) {
|
||||||
needsUnlock = false
|
needsUnlock = false
|
||||||
showLockedOverlay(false)
|
unlockInProgress = false
|
||||||
|
refreshLogProtection()
|
||||||
|
}
|
||||||
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) {
|
||||||
|
unlockInProgress = false
|
||||||
|
if (isProtectedLogPage()) navigateTo(R.id.nav_alerts)
|
||||||
}
|
}
|
||||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) { finishAndRemoveTask() }
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +132,24 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
|||||||
binding.lockOverlay.visibility = if (visible) View.VISIBLE else View.GONE
|
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 {
|
||||||
navigateTo(item.itemId)
|
navigateTo(item.itemId)
|
||||||
if (!permanentSidebar) {
|
if (!permanentSidebar) {
|
||||||
@@ -129,23 +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 == Page.LOG_DISPLAY -> LogDisplayFragment()
|
Page.ALERTS -> AlertsFragment()
|
||||||
page == Page.FILTER_LOGGING -> FilterLoggingFragment()
|
Page.PROFILES -> ProfilesFragment()
|
||||||
page == Page.FILTER_APPS -> FilterAppsFragment.newInstance()
|
Page.RULES -> RulesFragment()
|
||||||
else -> PageFragment.newInstance(page.titleRes)
|
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) {
|
||||||
@@ -251,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) {
|
||||||
@@ -279,22 +328,56 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
|||||||
return visible
|
return visible
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyPageTitleVisibility() {
|
|
||||||
(supportFragmentManager.findFragmentById(R.id.content_frame) as? PageFragment)
|
|
||||||
?.setPageTitleVisible(permanentSidebar)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun syncDrawerToggle() {
|
private fun syncDrawerToggle() {
|
||||||
drawerToggle.syncState()
|
drawerToggle.syncState()
|
||||||
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,
|
||||||
) {
|
) {
|
||||||
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),
|
||||||
|
ALERTS(R.id.nav_alerts, R.string.page_alerts),
|
||||||
|
PROFILES(R.id.nav_profiles, R.string.page_profiles),
|
||||||
|
RULES(R.id.nav_rules, R.string.page_rules),
|
||||||
|
APPS(R.id.nav_apps, R.string.page_apps),
|
||||||
|
NOTIFICATION_VISIBILITY(R.id.nav_notification_visibility, R.string.page_notification_visibility),
|
||||||
|
CARDS_LAYOUT(R.id.nav_cards_layout, R.string.page_cards_layout),
|
||||||
|
MISCELLANEOUS(R.id.nav_miscellaneous, R.string.page_miscellaneous),
|
||||||
LOG_DISPLAY(R.id.nav_log_display, R.string.page_log_display),
|
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_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging),
|
||||||
FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps),
|
FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps),
|
||||||
@@ -307,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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-15
@@ -1,19 +1,20 @@
|
|||||||
package se.ajpanton.notificationlog
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
|
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
||||||
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
|
import se.ajpanton.notificationsmaster.module.VisibilityPolicySync
|
||||||
import se.ajpanton.notificationlog.settings.LoggingRuleStore
|
import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
|
||||||
import se.ajpanton.notificationlog.settings.LoggingType
|
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
||||||
import se.ajpanton.notificationlog.settings.NotificationListenerComponentController
|
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
|
||||||
import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore
|
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
|
||||||
|
|
||||||
class NotificationLogApplication : Application() {
|
class NotificationsMasterApplication : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
synchronizeListenerComponent()
|
synchronizeListenerComponent()
|
||||||
Thread(::removeStaleAppStorage, "notification-log-storage-cleanup").start()
|
VisibilityPolicySync.publishStored(this)
|
||||||
|
Thread(::removeStaleAppStorage, "notifications-master-storage-cleanup").start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,12 +23,7 @@ class NotificationLogApplication : Application() {
|
|||||||
* Reconcile it before the UI starts so fresh default rules can bind again.
|
* Reconcile it before the UI starts so fresh default rules can bind again.
|
||||||
*/
|
*/
|
||||||
private fun synchronizeListenerComponent() {
|
private fun synchronizeListenerComponent() {
|
||||||
val rules = LoggingRuleStore(this)
|
NotificationListenerComponentController.synchronize(this)
|
||||||
NotificationListenerComponentController.update(
|
|
||||||
this,
|
|
||||||
LoggingType.entries.associateWith(rules::ruleFor),
|
|
||||||
PerAppEventSettingsStore(this).hasEnabledEventOverride(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeStaleAppStorage() {
|
private fun removeStaleAppStorage() {
|
||||||
@@ -36,6 +32,7 @@ class NotificationLogApplication : Application() {
|
|||||||
).mapTo(mutableSetOf()) { it.packageName }
|
).mapTo(mutableSetOf()) { it.packageName }
|
||||||
AppFilterSettingsStore(this).removeUninstalledPackages(installedPackages)
|
AppFilterSettingsStore(this).removeUninstalledPackages(installedPackages)
|
||||||
PerAppEventSettingsStore(this).removeUninstalledPackages(installedPackages)
|
PerAppEventSettingsStore(this).removeUninstalledPackages(installedPackages)
|
||||||
|
VisibilityPolicyStore(this).removeUninstalledPackages(installedPackages)
|
||||||
EncryptedNotificationLogStore(this).removeOrphanedImages()
|
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
-1
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
+38
-22
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -9,16 +9,17 @@ import android.view.View
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import se.ajpanton.notificationlog.databinding.FragmentSettingsBinding
|
import se.ajpanton.notificationsmaster.databinding.FragmentSettingsBinding
|
||||||
import se.ajpanton.notificationlog.settings.LogField
|
import se.ajpanton.notificationsmaster.settings.LogField
|
||||||
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
|
import se.ajpanton.notificationsmaster.settings.LogViewSettingsStore
|
||||||
import se.ajpanton.notificationlog.settings.AppLockStore
|
import se.ajpanton.notificationsmaster.settings.AppLockStore
|
||||||
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
|
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
||||||
import se.ajpanton.notificationlog.data.EncryptedImageStore
|
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
|
||||||
import se.ajpanton.notificationlog.capture.NotificationCaptureService
|
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
|
||||||
import se.ajpanton.notificationlog.export.LogExporter
|
import se.ajpanton.notificationsmaster.export.LogExporter
|
||||||
import se.ajpanton.notificationlog.settings.StorageLimits
|
import se.ajpanton.notificationsmaster.settings.StorageLimits
|
||||||
import se.ajpanton.notificationlog.settings.StorageLimitsStore
|
import se.ajpanton.notificationsmaster.settings.StorageLimitsStore
|
||||||
|
import se.ajpanton.notificationsmaster.settings.LogAppFilterStore
|
||||||
import java.util.zip.ZipOutputStream
|
import java.util.zip.ZipOutputStream
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -38,7 +39,9 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
|||||||
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
|
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
|
||||||
val lockStore = AppLockStore(requireContext())
|
val lockStore = AppLockStore(requireContext())
|
||||||
binding!!.appLock.isChecked = lockStore.enabled
|
binding!!.appLock.isChecked = lockStore.enabled
|
||||||
binding!!.appLock.setOnCheckedChangeListener { _, enabled -> lockStore.enabled = enabled }
|
binding!!.appLock.setOnCheckedChangeListener { _, enabled ->
|
||||||
|
(activity as? MainActivity)?.setAppLockEnabled(enabled) ?: run { lockStore.enabled = enabled }
|
||||||
|
}
|
||||||
bindStorageLimits()
|
bindStorageLimits()
|
||||||
}
|
}
|
||||||
override fun onDestroyView() { binding = null; super.onDestroyView() }
|
override fun onDestroyView() { binding = null; super.onDestroyView() }
|
||||||
@@ -75,23 +78,36 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmClearLogs() {
|
private fun confirmClearLogs() {
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
val filterStore = LogAppFilterStore(requireContext())
|
||||||
|
val dialog = MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle("Clear logs?")
|
.setTitle("Clear logs?")
|
||||||
.setMessage("This permanently removes every stored notification log and copied image.")
|
.setMessage("This permanently removes every stored notification log and copied image.")
|
||||||
.setNegativeButton("Cancel", null)
|
.setNegativeButton("Cancel", null)
|
||||||
.setPositiveButton("Clear") { _, _ ->
|
.setPositiveButton("Clear") { _, _ ->
|
||||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
clearLogs()
|
||||||
EncryptedNotificationLogStore(requireContext().applicationContext).clear()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.show()
|
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() {
|
private fun chooseExportFormat() {
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.setItems(arrayOf("CSV (no images)", "Formatted text (no images)", "HTML ZIP")) { _, which ->
|
.setItems(arrayOf("CSV (no images)", "Formatted text (no images)", "HTML ZIP")) { _, which ->
|
||||||
pendingExport = ExportFormat.entries[which]
|
pendingExport = ExportFormat.entries[which]
|
||||||
createDocument.launch("notification-log.${pendingExport!!.extension}")
|
createDocument.launch("notifications-master.${pendingExport!!.extension}")
|
||||||
}
|
}
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
@@ -132,7 +148,7 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
|||||||
private fun writeCsv(
|
private fun writeCsv(
|
||||||
writer: java.io.BufferedWriter,
|
writer: java.io.BufferedWriter,
|
||||||
logStore: EncryptedNotificationLogStore,
|
logStore: EncryptedNotificationLogStore,
|
||||||
settings: se.ajpanton.notificationlog.settings.LogViewSettings,
|
settings: se.ajpanton.notificationsmaster.settings.LogViewSettings,
|
||||||
context: android.content.Context,
|
context: android.content.Context,
|
||||||
) = writer.use {
|
) = writer.use {
|
||||||
it.write(LogExporter.csvHeader(settings))
|
it.write(LogExporter.csvHeader(settings))
|
||||||
@@ -145,7 +161,7 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
|||||||
private fun writeFormatted(
|
private fun writeFormatted(
|
||||||
writer: java.io.BufferedWriter,
|
writer: java.io.BufferedWriter,
|
||||||
logStore: EncryptedNotificationLogStore,
|
logStore: EncryptedNotificationLogStore,
|
||||||
settings: se.ajpanton.notificationlog.settings.LogViewSettings,
|
settings: se.ajpanton.notificationsmaster.settings.LogViewSettings,
|
||||||
context: android.content.Context,
|
context: android.content.Context,
|
||||||
) = writer.use {
|
) = writer.use {
|
||||||
val headers = LogExporter.headers(settings)
|
val headers = LogExporter.headers(settings)
|
||||||
@@ -165,11 +181,11 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
|||||||
private fun writeHtmlZip(
|
private fun writeHtmlZip(
|
||||||
zip: ZipOutputStream,
|
zip: ZipOutputStream,
|
||||||
logStore: EncryptedNotificationLogStore,
|
logStore: EncryptedNotificationLogStore,
|
||||||
settings: se.ajpanton.notificationlog.settings.LogViewSettings,
|
settings: se.ajpanton.notificationsmaster.settings.LogViewSettings,
|
||||||
context: android.content.Context,
|
context: android.content.Context,
|
||||||
) = zip.use { zipOutput ->
|
) = zip.use { zipOutput ->
|
||||||
val imageStore = EncryptedImageStore(context)
|
val imageStore = EncryptedImageStore(context)
|
||||||
zipOutput.putNextEntry(java.util.zip.ZipEntry("notification-log.html"))
|
zipOutput.putNextEntry(java.util.zip.ZipEntry("notifications-master.html"))
|
||||||
zipOutput.write(LogExporter.htmlStart(settings).encodeToByteArray())
|
zipOutput.write(LogExporter.htmlStart(settings).encodeToByteArray())
|
||||||
logStore.forEachNewest { entry ->
|
logStore.forEachNewest { entry ->
|
||||||
val imagePath = entry.imageId
|
val imagePath = entry.imageId
|
||||||
+135
-59
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
@@ -12,23 +12,25 @@ import android.content.ComponentName
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.text.TextPaint
|
import android.text.TextPaint
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
|
import android.view.Gravity
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import se.ajpanton.notificationlog.data.EncryptedImageStore
|
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
|
||||||
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
|
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
||||||
import se.ajpanton.notificationlog.data.NewestLogCursor
|
import se.ajpanton.notificationsmaster.data.NewestLogCursor
|
||||||
import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding
|
import se.ajpanton.notificationsmaster.databinding.FragmentViewLogsBinding
|
||||||
import se.ajpanton.notificationlog.model.NotificationAction
|
import se.ajpanton.notificationsmaster.model.NotificationAction
|
||||||
import se.ajpanton.notificationlog.model.NotificationLogEntry
|
import se.ajpanton.notificationsmaster.model.NotificationLogEntry
|
||||||
import se.ajpanton.notificationlog.settings.LogField
|
import se.ajpanton.notificationsmaster.settings.LogField
|
||||||
import se.ajpanton.notificationlog.settings.LogViewSettings
|
import se.ajpanton.notificationsmaster.settings.LogViewSettings
|
||||||
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
|
import se.ajpanton.notificationsmaster.settings.LogViewSettingsStore
|
||||||
import se.ajpanton.notificationlog.settings.TimestampFormatter
|
import se.ajpanton.notificationsmaster.settings.LogAppFilterStore
|
||||||
import se.ajpanton.notificationlog.settings.DisplayEvent
|
import se.ajpanton.notificationsmaster.settings.TimestampFormatter
|
||||||
import se.ajpanton.notificationlog.capture.NotificationCaptureService
|
import se.ajpanton.notificationsmaster.settings.DisplayEvent
|
||||||
|
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.doOnPreDraw
|
import androidx.core.view.doOnPreDraw
|
||||||
@@ -43,16 +45,18 @@ import kotlinx.coroutines.withContext
|
|||||||
class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
||||||
private var binding: FragmentViewLogsBinding? = null
|
private var binding: FragmentViewLogsBinding? = null
|
||||||
private val expandedIds = mutableSetOf<String>()
|
private val expandedIds = mutableSetOf<String>()
|
||||||
private val rows = mutableListOf<LogRow>()
|
private val rows = mutableListOf<NotificationLogEntry>()
|
||||||
private var nextCursor: NewestLogCursor? = null
|
private var nextCursor: NewestLogCursor? = null
|
||||||
private var loading = false
|
private var loading = false
|
||||||
private var noMoreRows = false
|
private var noMoreRows = false
|
||||||
private var loadGeneration = 0
|
private var loadGeneration = 0
|
||||||
private val logAdapter = LogAdapter()
|
private val logAdapter = LogAdapter()
|
||||||
|
private lateinit var appFilterStore: LogAppFilterStore
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding = FragmentViewLogsBinding.bind(view)
|
binding = FragmentViewLogsBinding.bind(view)
|
||||||
|
appFilterStore = LogAppFilterStore(requireContext())
|
||||||
binding!!.logsRefresh.setOnRefreshListener { loadLogs(reset = true) }
|
binding!!.logsRefresh.setOnRefreshListener { loadLogs(reset = true) }
|
||||||
binding!!.logList.layoutManager = LinearLayoutManager(requireContext())
|
binding!!.logList.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding!!.logList.adapter = logAdapter
|
binding!!.logList.adapter = logAdapter
|
||||||
@@ -82,7 +86,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun loadLogs(reset: Boolean) {
|
private fun loadLogs(reset: Boolean) {
|
||||||
if (loading) return
|
if (loading && !reset) return
|
||||||
if (!reset && noMoreRows) return
|
if (!reset && noMoreRows) return
|
||||||
val context = requireContext().applicationContext
|
val context = requireContext().applicationContext
|
||||||
if (reset) {
|
if (reset) {
|
||||||
@@ -98,31 +102,94 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
val page = withContext(Dispatchers.IO) {
|
val page = withContext(Dispatchers.IO) {
|
||||||
EncryptedNotificationLogStore(context).readNewest(nextCursor, PAGE_SIZE)
|
EncryptedNotificationLogStore(context).readNewest(nextCursor, PAGE_SIZE)
|
||||||
}
|
}
|
||||||
if (generation == loadGeneration) {
|
if (generation != loadGeneration) return@launch
|
||||||
nextCursor = page.nextCursor
|
nextCursor = page.nextCursor
|
||||||
noMoreRows = nextCursor == null
|
noMoreRows = nextCursor == null
|
||||||
val addedRows = page.entries.map(::LogRow)
|
val visibleBefore = visibleRows(LogViewSettingsStore(requireContext()).load()).size
|
||||||
rows += addedRows
|
rows += page.entries
|
||||||
binding?.let(::render)
|
binding?.let(::render)
|
||||||
}
|
val visibleAfter = visibleRows(LogViewSettingsStore(requireContext()).load()).size
|
||||||
loading = false
|
val skipFilteredPage = appFilterStore.selectedPackages().isNotEmpty() &&
|
||||||
binding?.logsRefresh?.isRefreshing = false
|
visibleAfter == visibleBefore && !noMoreRows
|
||||||
|
loading = false
|
||||||
|
binding?.logsRefresh?.isRefreshing = false
|
||||||
|
if (skipFilteredPage) loadLogs(reset = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun render(view: FragmentViewLogsBinding) {
|
private fun render(view: FragmentViewLogsBinding) {
|
||||||
view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE
|
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()) {
|
view.emptyView.text = if (hasNotificationAccess()) {
|
||||||
"No logs yet."
|
if (appFilterStore.selectedPackages().isEmpty()) "No logs yet." else "No logs match the app filter."
|
||||||
} else {
|
} else {
|
||||||
"Notification access is disabled. Enable it in Settings."
|
"Notification access is disabled. Enable it in Settings."
|
||||||
}
|
}
|
||||||
val settings = LogViewSettingsStore(requireContext()).load()
|
|
||||||
val visibleRows = rows.filter { eventFor(it.entry.action) in settings.visibleEvents }
|
|
||||||
logAdapter.submit(visibleRows, settings, metadataColumnWidth(view.logList.width, visibleRows, settings))
|
logAdapter.submit(visibleRows, settings, metadataColumnWidth(view.logList.width, visibleRows, settings))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logRowView(row: LogRow, settings: LogViewSettings, metadataColumnWidth: Int): View = LinearLayout(requireContext()).apply {
|
/** 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
|
orientation = LinearLayout.HORIZONTAL
|
||||||
gravity = android.view.Gravity.TOP
|
gravity = android.view.Gravity.TOP
|
||||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
@@ -131,14 +198,14 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
|
|
||||||
fun renderExpanded(expanded: Boolean) {
|
fun renderExpanded(expanded: Boolean) {
|
||||||
removeAllViews()
|
removeAllViews()
|
||||||
val metadata = metadataValues(row.entry, settings)
|
val metadata = metadataValues(entry, settings)
|
||||||
val contents = fieldValue(LogField.CONTENTS, row.entry, settings)
|
val contents = entry.contents
|
||||||
val hasContents = LogField.CONTENTS in settings.visibleFields && !contents.isNullOrEmpty()
|
val hasContents = LogField.CONTENTS in settings.visibleFields && !contents.isNullOrEmpty()
|
||||||
val toggleExpanded = {
|
val toggleExpanded = {
|
||||||
val collapsing = expanded
|
val collapsing = expanded
|
||||||
val collapseAnchor = if (collapsing) captureCollapseAnchor(this@apply) else null
|
val collapseAnchor = if (collapsing) captureCollapseAnchor(this@apply) else null
|
||||||
if (!expandedIds.add(row.entry.id)) expandedIds.remove(row.entry.id)
|
if (!expandedIds.add(entry.id)) expandedIds.remove(entry.id)
|
||||||
renderExpanded(row.entry.id in expandedIds)
|
renderExpanded(entry.id in expandedIds)
|
||||||
if (collapsing) {
|
if (collapsing) {
|
||||||
val highlight = { highlightCollapsedRow(this@apply) }
|
val highlight = { highlightCollapsedRow(this@apply) }
|
||||||
if (collapseAnchor != null) {
|
if (collapseAnchor != null) {
|
||||||
@@ -149,7 +216,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
}
|
}
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
val metadataCell = metadataCell(metadata, expanded, toggleExpanded, row.entry.id).apply {
|
val metadataCell = metadataCell(metadata, expanded, toggleExpanded, entry.id).apply {
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
if (hasContents) metadataColumnWidth else LinearLayout.LayoutParams.MATCH_PARENT,
|
if (hasContents) metadataColumnWidth else LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
@@ -159,7 +226,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
}
|
}
|
||||||
if (metadata.isNotEmpty()) addView(metadataCell)
|
if (metadata.isNotEmpty()) addView(metadataCell)
|
||||||
if (hasContents) {
|
if (hasContents) {
|
||||||
val contentsCell = contentsCell(row, contents!!, metadata.size.coerceAtLeast(1), expanded, toggleExpanded).apply {
|
val contentsCell = contentsCell(entry, contents!!, metadata.size.coerceAtLeast(1), expanded, toggleExpanded).apply {
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
if (metadata.isNotEmpty()) 0 else LinearLayout.LayoutParams.MATCH_PARENT,
|
if (metadata.isNotEmpty()) 0 else LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
@@ -170,9 +237,9 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderExpanded(row.entry.id in expandedIds)
|
renderExpanded(entry.id in expandedIds)
|
||||||
setOnLongClickListener {
|
setOnLongClickListener {
|
||||||
showDeleteDialog(row.entry.id)
|
showDeleteDialog(entry.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +273,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun contentsCell(
|
private fun contentsCell(
|
||||||
row: LogRow,
|
entry: NotificationLogEntry,
|
||||||
contents: String,
|
contents: String,
|
||||||
collapsedLines: Int,
|
collapsedLines: Int,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
@@ -223,15 +290,15 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
setLineSpacing(0f, 1f)
|
setLineSpacing(0f, 1f)
|
||||||
}
|
}
|
||||||
addView(message)
|
addView(message)
|
||||||
if (expanded && row.entry.action == NotificationAction.EDITED && !row.entry.previousContents.isNullOrEmpty()) {
|
if (expanded && entry.action == NotificationAction.EDITED && !entry.previousContents.isNullOrEmpty()) {
|
||||||
|
addView(previousDivider())
|
||||||
addView(TextView(context).apply {
|
addView(TextView(context).apply {
|
||||||
text = "Previous: ${row.entry.previousContents}"
|
text = entry.previousContents
|
||||||
setLineSpacing(0f, 1f)
|
setLineSpacing(0f, 1f)
|
||||||
setPadding(0, dp(4), 0, 0)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (expanded && row.entry.imageId != null) {
|
if (expanded && entry.imageId != null) {
|
||||||
val imageId = row.entry.imageId
|
val imageId = entry.imageId
|
||||||
val appContext = requireContext().applicationContext
|
val appContext = requireContext().applicationContext
|
||||||
val contentsContainer = this
|
val contentsContainer = this
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
@@ -257,7 +324,25 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setOnClickListener { onToggleExpanded() }
|
setOnClickListener { onToggleExpanded() }
|
||||||
setOnLongClickListener { showDeleteDialog(row.entry.id) }
|
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 {
|
private fun metadataValues(entry: NotificationLogEntry, settings: LogViewSettings): List<MetadataValue> = buildList {
|
||||||
@@ -267,14 +352,6 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
if (LogField.ACTION in settings.visibleFields) add(MetadataValue(actionLabel(entry.action), 14f, indented = true))
|
if (LogField.ACTION in settings.visibleFields) add(MetadataValue(actionLabel(entry.action), 14f, indented = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fieldValue(field: LogField, entry: NotificationLogEntry, settings: LogViewSettings): String? = when (field) {
|
|
||||||
LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings, entry.eventTimeZoneId)
|
|
||||||
LogField.APP_NAME -> entry.appName
|
|
||||||
LogField.PACKAGE_NAME -> entry.packageName
|
|
||||||
LogField.ACTION -> actionLabel(entry.action)
|
|
||||||
LogField.CONTENTS -> entry.contents
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun actionLabel(action: NotificationAction): String = when (action) {
|
private fun actionLabel(action: NotificationAction): String = when (action) {
|
||||||
NotificationAction.APPEARED -> "Appeared"
|
NotificationAction.APPEARED -> "Appeared"
|
||||||
NotificationAction.ALREADY_ACTIVE -> "Already active"
|
NotificationAction.ALREADY_ACTIVE -> "Already active"
|
||||||
@@ -297,8 +374,8 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
.getSystemService(NotificationManager::class.java)
|
.getSystemService(NotificationManager::class.java)
|
||||||
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
|
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
|
||||||
|
|
||||||
private fun metadataColumnWidth(availableWidth: Int, rows: List<LogRow>, settings: LogViewSettings): Int {
|
private fun metadataColumnWidth(availableWidth: Int, rows: List<NotificationLogEntry>, settings: LogViewSettings): Int {
|
||||||
val values = rows.flatMap { metadataValues(it.entry, settings) }
|
val values = rows.flatMap { metadataValues(it, settings) }
|
||||||
val widestTimestamp = values.filter { it.isTimestamp }
|
val widestTimestamp = values.filter { it.isTimestamp }
|
||||||
.maxOfOrNull(::metadataTextWidth)
|
.maxOfOrNull(::metadataTextWidth)
|
||||||
?: 0
|
?: 0
|
||||||
@@ -333,11 +410,11 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private inner class LogAdapter : RecyclerView.Adapter<LogAdapter.Holder>() {
|
private inner class LogAdapter : RecyclerView.Adapter<LogAdapter.Holder>() {
|
||||||
private var rows: List<LogRow> = emptyList()
|
private var rows: List<NotificationLogEntry> = emptyList()
|
||||||
private var settings = LogViewSettings()
|
private var settings = LogViewSettings()
|
||||||
private var metadataWidth = 0
|
private var metadataWidth = 0
|
||||||
|
|
||||||
fun submit(rows: List<LogRow>, settings: LogViewSettings, metadataWidth: Int) {
|
fun submit(rows: List<NotificationLogEntry>, settings: LogViewSettings, metadataWidth: Int) {
|
||||||
val previous = this.rows
|
val previous = this.rows
|
||||||
this.rows = rows
|
this.rows = rows
|
||||||
this.settings = settings
|
this.settings = settings
|
||||||
@@ -346,7 +423,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
override fun getOldListSize() = previous.size
|
override fun getOldListSize() = previous.size
|
||||||
override fun getNewListSize() = rows.size
|
override fun getNewListSize() = rows.size
|
||||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
||||||
previous[oldItemPosition].entry.id == rows[newItemPosition].entry.id
|
previous[oldItemPosition].id == rows[newItemPosition].id
|
||||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
||||||
previous[oldItemPosition] == rows[newItemPosition]
|
previous[oldItemPosition] == rows[newItemPosition]
|
||||||
}).dispatchUpdatesTo(this)
|
}).dispatchUpdatesTo(this)
|
||||||
@@ -462,7 +539,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
resources.displayMetrics,
|
resources.displayMetrics,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class LogRow(val entry: NotificationLogEntry)
|
private data class LoggedApp(val name: String, val packageName: String)
|
||||||
private data class MetadataValue(
|
private data class MetadataValue(
|
||||||
val text: String,
|
val text: String,
|
||||||
val textSize: Float,
|
val textSize: Float,
|
||||||
@@ -481,7 +558,6 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SCROLL_TO_TOP_THRESHOLD_DP = 120
|
const val SCROLL_TO_TOP_THRESHOLD_DP = 120
|
||||||
const val LOAD_MORE_THRESHOLD_DP = 480
|
|
||||||
const val PAGE_SIZE = 80
|
const val PAGE_SIZE = 80
|
||||||
const val IMAGE_VIEWER_TAG = "image-viewer"
|
const val IMAGE_VIEWER_TAG = "image-viewer"
|
||||||
const val MINIMUM_METADATA_COLUMN_DP = 120
|
const val MINIMUM_METADATA_COLUMN_DP = 120
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog
|
package se.ajpanton.notificationsmaster
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.alerts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import se.ajpanton.notificationsmaster.data.EncryptedAtomicFile
|
||||||
|
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
||||||
|
import se.ajpanton.notificationsmaster.module.AlertPolicySync
|
||||||
|
|
||||||
|
/** Atomic, encrypted storage for alert settings. An unsupported format fails closed. */
|
||||||
|
class AlertConfigurationStore(context: Context) {
|
||||||
|
private val context = context.applicationContext
|
||||||
|
private val file = EncryptedAtomicFile(this.context, FILE_NAME)
|
||||||
|
|
||||||
|
fun load(): AlertConfiguration = synchronized(lock) {
|
||||||
|
if (!file.exists) cached = null
|
||||||
|
cached ?: file.read()?.let(AlertConfigurationJson::decode)?.also { cached = it }
|
||||||
|
?: AlertConfiguration()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(configuration: AlertConfiguration) {
|
||||||
|
synchronized(lock) {
|
||||||
|
file.write(AlertConfigurationJson.encode(configuration))
|
||||||
|
cached = configuration
|
||||||
|
}
|
||||||
|
NotificationListenerComponentController.synchronize(context)
|
||||||
|
AlertPolicySync.publish(context, configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FILE_NAME = "alert-configuration.bin"
|
||||||
|
val lock = Any()
|
||||||
|
var cached: AlertConfiguration? = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object AlertConfigurationJson {
|
||||||
|
fun encode(configuration: AlertConfiguration): ByteArray = JSONObject()
|
||||||
|
.put("version", 1)
|
||||||
|
.put("enabled", configuration.enabled)
|
||||||
|
.put("ignoreRoutineUpdates", configuration.ignoreRoutineUpdates)
|
||||||
|
.put("profiles", JSONArray().apply { configuration.profiles.forEach { put(it.toJson()) } })
|
||||||
|
.put("apps", JSONArray().apply { configuration.appSettings.forEach { put(it.toJson()) } })
|
||||||
|
.put("rules", JSONArray().apply { configuration.rules.forEach { put(it.toJson()) } })
|
||||||
|
.put("queue", JSONObject().put("maximumLength", configuration.queueSettings.maximumLength)
|
||||||
|
.put("overflow", configuration.queueSettings.overflow.name))
|
||||||
|
.toString().encodeToByteArray()
|
||||||
|
|
||||||
|
fun decode(bytes: ByteArray): AlertConfiguration {
|
||||||
|
val root = JSONObject(bytes.decodeToString())
|
||||||
|
require(root.getInt("version") == 1) { "Unsupported alert-configuration format." }
|
||||||
|
return AlertConfiguration(
|
||||||
|
enabled = root.getBoolean("enabled"),
|
||||||
|
ignoreRoutineUpdates = root.getBoolean("ignoreRoutineUpdates"),
|
||||||
|
profiles = root.getJSONArray("profiles").map { (it as JSONObject).toProfile() },
|
||||||
|
appSettings = root.getJSONArray("apps").map { (it as JSONObject).toAppSettings() },
|
||||||
|
rules = root.getJSONArray("rules").map { (it as JSONObject).toRule() },
|
||||||
|
queueSettings = root.getJSONObject("queue").let {
|
||||||
|
AlertQueueSettings(it.getInt("maximumLength"), AlertQueueOverflow.valueOf(it.getString("overflow")))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AlertProfile.toJson() = JSONObject()
|
||||||
|
.put("id", id).put("name", name).put("soundUri", soundUri)
|
||||||
|
.put("vibrationPattern", JSONArray(vibrationPattern))
|
||||||
|
|
||||||
|
private fun AlertAppSettings.toJson() = JSONObject()
|
||||||
|
.put("packageName", packageName).put("allowMultipleMatches", allowMultipleMatches)
|
||||||
|
|
||||||
|
private fun AlertRule.toJson() = JSONObject()
|
||||||
|
.put("id", id).put("packageName", packageName).put("order", order)
|
||||||
|
.put("sources", JSONArray(sources.map { it.name }))
|
||||||
|
.put("matcher", matcher.toJson()).put("outcome", outcome.name).put("profileId", profileId)
|
||||||
|
.put("playToCompletion", playToCompletion).put("allowDuringDnd", allowDuringDnd)
|
||||||
|
.put("enabled", enabled).put("name", name)
|
||||||
|
|
||||||
|
private fun AlertTextMatcher.toJson() = JSONObject()
|
||||||
|
.put("field", field.name).put("mode", mode.name).put("value", value)
|
||||||
|
.put("caseSensitive", caseSensitive)
|
||||||
|
|
||||||
|
private fun JSONObject.toProfile() = AlertProfile(
|
||||||
|
getString("id"), getString("name"), nullableString("soundUri"),
|
||||||
|
getJSONArray("vibrationPattern").map { (it as Number).toLong() },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JSONObject.toAppSettings() = AlertAppSettings(
|
||||||
|
getString("packageName"), getBoolean("allowMultipleMatches"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JSONObject.toRule() = AlertRule(
|
||||||
|
id = getString("id"),
|
||||||
|
packageName = getString("packageName"),
|
||||||
|
order = getInt("order"),
|
||||||
|
sources = getJSONArray("sources").map { AlertSource.valueOf(it as String) }.toSet(),
|
||||||
|
matcher = getJSONObject("matcher").let {
|
||||||
|
AlertTextMatcher(
|
||||||
|
AlertTextField.valueOf(it.getString("field")),
|
||||||
|
AlertTextMode.valueOf(it.getString("mode")),
|
||||||
|
it.getString("value"),
|
||||||
|
it.getBoolean("caseSensitive"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
outcome = AlertOutcome.valueOf(getString("outcome")),
|
||||||
|
profileId = nullableString("profileId"),
|
||||||
|
playToCompletion = getBoolean("playToCompletion"),
|
||||||
|
allowDuringDnd = getBoolean("allowDuringDnd"),
|
||||||
|
enabled = getBoolean("enabled"),
|
||||||
|
name = nullableString("name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JSONObject.nullableString(name: String) = if (isNull(name)) null else getString(name)
|
||||||
|
|
||||||
|
private fun <T> JSONArray.map(transform: (Any) -> T) = List(length()) { transform(get(it)) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.alerts
|
||||||
|
|
||||||
|
import com.google.re2j.Pattern
|
||||||
|
|
||||||
|
enum class AlertSource {
|
||||||
|
NOTIFICATION_POST,
|
||||||
|
NOTIFICATION_UPDATE,
|
||||||
|
;
|
||||||
|
|
||||||
|
fun label() = when (this) {
|
||||||
|
NOTIFICATION_POST -> "Appearing"
|
||||||
|
NOTIFICATION_UPDATE -> "Edits"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AlertOutcome { PLAY_PROFILE, SILENCE_ORIGINAL, PASS_THROUGH }
|
||||||
|
|
||||||
|
enum class AlertTextField { ANY_TEXT, TITLE, BODY, SENDER }
|
||||||
|
|
||||||
|
enum class AlertTextMode { ANY, CONTAINS, REGEX }
|
||||||
|
|
||||||
|
enum class AlertQueueOverflow { DROP_OLDEST, DROP_NEWEST }
|
||||||
|
|
||||||
|
data class AlertProfile(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val soundUri: String? = null,
|
||||||
|
val vibrationPattern: List<Long> = emptyList(),
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(id.isNotBlank() && name.isNotBlank())
|
||||||
|
require(soundUri?.isNotBlank() != false)
|
||||||
|
require(soundUri != null || vibrationPattern.any { it > 0 })
|
||||||
|
require(vibrationPattern.all { it >= 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AlertTextMatcher(
|
||||||
|
val field: AlertTextField = AlertTextField.ANY_TEXT,
|
||||||
|
val mode: AlertTextMode = AlertTextMode.ANY,
|
||||||
|
val value: String = "",
|
||||||
|
val caseSensitive: Boolean = false,
|
||||||
|
) {
|
||||||
|
private val pattern = if (mode == AlertTextMode.REGEX) Pattern.compile(
|
||||||
|
value,
|
||||||
|
if (caseSensitive) 0 else Pattern.CASE_INSENSITIVE,
|
||||||
|
) else null
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(mode == AlertTextMode.ANY || value.isNotBlank())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun matches(event: AlertEvent): Boolean = when (mode) {
|
||||||
|
AlertTextMode.ANY -> true
|
||||||
|
AlertTextMode.CONTAINS -> text(event)?.contains(value, ignoreCase = !caseSensitive) == true
|
||||||
|
AlertTextMode.REGEX -> text(event)?.let { pattern!!.matcher(it).find() } == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun text(event: AlertEvent): String? = when (field) {
|
||||||
|
AlertTextField.TITLE -> event.title
|
||||||
|
AlertTextField.BODY -> event.body
|
||||||
|
AlertTextField.SENDER -> event.sender
|
||||||
|
AlertTextField.ANY_TEXT -> listOfNotNull(event.title, event.body, event.sender).joinToString("\n").ifBlank { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AlertRule(
|
||||||
|
val id: String,
|
||||||
|
val packageName: String,
|
||||||
|
val order: Int,
|
||||||
|
val sources: Set<AlertSource>,
|
||||||
|
val matcher: AlertTextMatcher = AlertTextMatcher(),
|
||||||
|
val outcome: AlertOutcome,
|
||||||
|
val profileId: String? = null,
|
||||||
|
val playToCompletion: Boolean = false,
|
||||||
|
val allowDuringDnd: Boolean = false,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val name: String? = null,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(id.isNotBlank() && packageName.isNotBlank() && sources.isNotEmpty())
|
||||||
|
require((outcome == AlertOutcome.PLAY_PROFILE) == (profileId != null))
|
||||||
|
require(name?.isNotBlank() != false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun groupingKey() = AlertRuleGroupingKey(
|
||||||
|
sources = sources,
|
||||||
|
matcher = matcher,
|
||||||
|
outcome = outcome,
|
||||||
|
profileId = profileId,
|
||||||
|
playToCompletion = playToCompletion,
|
||||||
|
allowDuringDnd = allowDuringDnd,
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AlertRuleGroupingKey(
|
||||||
|
val sources: Set<AlertSource>,
|
||||||
|
val matcher: AlertTextMatcher,
|
||||||
|
val outcome: AlertOutcome,
|
||||||
|
val profileId: String?,
|
||||||
|
val playToCompletion: Boolean,
|
||||||
|
val allowDuringDnd: Boolean,
|
||||||
|
val enabled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AlertAppSettings(
|
||||||
|
val packageName: String,
|
||||||
|
val allowMultipleMatches: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AlertQueueSettings(
|
||||||
|
val maximumLength: Int = 3,
|
||||||
|
val overflow: AlertQueueOverflow = AlertQueueOverflow.DROP_OLDEST,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(maximumLength in 1..10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AlertConfiguration(
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val ignoreRoutineUpdates: Boolean = true,
|
||||||
|
val profiles: List<AlertProfile> = emptyList(),
|
||||||
|
val appSettings: List<AlertAppSettings> = emptyList(),
|
||||||
|
val rules: List<AlertRule> = emptyList(),
|
||||||
|
val queueSettings: AlertQueueSettings = AlertQueueSettings(),
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(profiles.map { it.id }.distinct().size == profiles.size)
|
||||||
|
require(appSettings.map { it.packageName }.distinct().size == appSettings.size)
|
||||||
|
require(rules.map { it.id }.distinct().size == rules.size)
|
||||||
|
require(rules.filter { it.outcome == AlertOutcome.PLAY_PROFILE }.all { rule ->
|
||||||
|
rule.profileId in profiles.map { it.id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasEnabledRules get() = enabled && rules.any { it.enabled }
|
||||||
|
val needsListener get() = hasEnabledRules
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AlertEvent(
|
||||||
|
val packageName: String,
|
||||||
|
val source: AlertSource,
|
||||||
|
val title: String? = null,
|
||||||
|
val body: String? = null,
|
||||||
|
val sender: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AlertDecision(
|
||||||
|
val ruleId: String,
|
||||||
|
val outcome: AlertOutcome,
|
||||||
|
val profile: AlertProfile? = null,
|
||||||
|
val playToCompletion: Boolean,
|
||||||
|
val allowDuringDnd: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Resolved at match time so queued alerts never point at mutable settings. */
|
||||||
|
data class AlertSnapshot(
|
||||||
|
val soundUri: String?,
|
||||||
|
val vibrationPattern: List<Long>,
|
||||||
|
val playToCompletion: Boolean,
|
||||||
|
val allowDuringDnd: Boolean,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(soundUri != null || vibrationPattern.any { it > 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun AlertDecision.snapshot(): AlertSnapshot? = profile?.let {
|
||||||
|
AlertSnapshot(it.soundUri, it.vibrationPattern, playToCompletion, allowDuringDnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AlertEvaluation(
|
||||||
|
val decisions: List<AlertDecision>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun AlertEvaluation.suppressesOriginal() =
|
||||||
|
decisions.any { it.outcome != AlertOutcome.PASS_THROUGH }
|
||||||
+18
@@ -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
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
package se.ajpanton.notificationlog.capture
|
package se.ajpanton.notificationsmaster.capture
|
||||||
|
|
||||||
import se.ajpanton.notificationlog.model.NotificationAction
|
import se.ajpanton.notificationsmaster.model.NotificationAction
|
||||||
|
|
||||||
/** Applies the global, Android-provided group-summary classification without inspecting text. */
|
/** Applies the global, Android-provided group-summary classification without inspecting text. */
|
||||||
object GroupSummaryPolicy {
|
object GroupSummaryPolicy {
|
||||||
+78
-39
@@ -1,20 +1,30 @@
|
|||||||
package se.ajpanton.notificationlog.capture
|
package se.ajpanton.notificationsmaster.capture
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.service.notification.NotificationListenerService
|
import android.service.notification.NotificationListenerService
|
||||||
import android.service.notification.StatusBarNotification
|
import android.service.notification.StatusBarNotification
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
|
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
||||||
import se.ajpanton.notificationlog.data.EncryptedImageStore
|
import se.ajpanton.notificationsmaster.alerts.AlertEvent
|
||||||
import se.ajpanton.notificationlog.model.NotificationAction
|
import se.ajpanton.notificationsmaster.alerts.AlertNotificationDispatcher
|
||||||
import se.ajpanton.notificationlog.model.NotificationLogEntry
|
import se.ajpanton.notificationsmaster.alerts.AlertPlaybackController
|
||||||
import se.ajpanton.notificationlog.settings.LoggingRuleStore
|
import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings
|
||||||
import se.ajpanton.notificationlog.settings.LoggingType
|
import se.ajpanton.notificationsmaster.alerts.AlertSource
|
||||||
import se.ajpanton.notificationlog.settings.NotificationRuleEvaluator
|
import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer
|
||||||
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
|
import se.ajpanton.notificationsmaster.module.AlertPolicySync
|
||||||
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
|
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
||||||
import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore
|
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.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@@ -27,6 +37,9 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
private lateinit var appFilterStore: AppFilterSettingsStore
|
private lateinit var appFilterStore: AppFilterSettingsStore
|
||||||
private lateinit var perAppEventSettings: PerAppEventSettingsStore
|
private lateinit var perAppEventSettings: PerAppEventSettingsStore
|
||||||
private lateinit var imageStore: EncryptedImageStore
|
private lateinit var imageStore: EncryptedImageStore
|
||||||
|
private lateinit var alertConfigurationStore: AlertConfigurationStore
|
||||||
|
private lateinit var alertPlayback: AlertPlaybackController
|
||||||
|
private var alertQueueSettings = AlertQueueSettings()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
@@ -36,6 +49,8 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
appFilterStore = AppFilterSettingsStore(this)
|
appFilterStore = AppFilterSettingsStore(this)
|
||||||
perAppEventSettings = PerAppEventSettingsStore(this)
|
perAppEventSettings = PerAppEventSettingsStore(this)
|
||||||
imageStore = EncryptedImageStore(this)
|
imageStore = EncryptedImageStore(this)
|
||||||
|
alertConfigurationStore = AlertConfigurationStore(this)
|
||||||
|
alertPlayback = AlertPlaybackController(AndroidAlertEffectPlayer(this)) { alertQueueSettings }
|
||||||
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
||||||
Thread(runnable, "notification-log-writer")
|
Thread(runnable, "notification-log-writer")
|
||||||
}
|
}
|
||||||
@@ -43,9 +58,11 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
|
|
||||||
override fun onListenerConnected() {
|
override fun onListenerConnected() {
|
||||||
super.onListenerConnected()
|
super.onListenerConnected()
|
||||||
|
AlertPolicySync.publishPlaybackState(this, true)
|
||||||
getActiveNotifications()?.forEach { sbn ->
|
getActiveNotifications()?.forEach { sbn ->
|
||||||
|
if (DebugNotifications.isManaged(this, sbn)) return@forEach
|
||||||
val snapshot = NotificationContents.snapshot(sbn)
|
val snapshot = NotificationContents.snapshot(sbn)
|
||||||
SeenApps.markSeen(snapshot.packageName)
|
SeenApps.markSeen(this, snapshot.packageName)
|
||||||
activeNotifications[snapshot.key] = snapshot
|
activeNotifications[snapshot.key] = snapshot
|
||||||
record(
|
record(
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -58,27 +75,34 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
||||||
|
if (DebugNotifications.isManaged(this, sbn)) return
|
||||||
val snapshot = NotificationContents.snapshot(sbn)
|
val snapshot = NotificationContents.snapshot(sbn)
|
||||||
SeenApps.markSeen(snapshot.packageName)
|
SeenApps.markSeen(this, snapshot.packageName)
|
||||||
val previous = activeNotifications.put(snapshot.key, snapshot)
|
val previous = activeNotifications.put(snapshot.key, snapshot)
|
||||||
when {
|
when {
|
||||||
previous == null -> record(
|
previous == null -> {
|
||||||
snapshot,
|
|
||||||
NotificationAction.APPEARED,
|
|
||||||
LoggingType.APPEARING,
|
|
||||||
includeContents = true,
|
|
||||||
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
|
|
||||||
)
|
|
||||||
NotificationChangeClassifier.isMeaningfulEdit(previous, snapshot) &&
|
|
||||||
!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates) ->
|
|
||||||
record(
|
record(
|
||||||
snapshot,
|
snapshot,
|
||||||
NotificationAction.EDITED,
|
NotificationAction.APPEARED,
|
||||||
LoggingType.EDITS,
|
LoggingType.APPEARING,
|
||||||
includeContents = true,
|
includeContents = true,
|
||||||
previousSnapshot = previous,
|
|
||||||
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,12 +111,15 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
rankingMap: RankingMap,
|
rankingMap: RankingMap,
|
||||||
reason: Int,
|
reason: Int,
|
||||||
) {
|
) {
|
||||||
|
if (DebugNotifications.isManaged(this, sbn)) return
|
||||||
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn)
|
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn)
|
||||||
SeenApps.markSeen(snapshot.packageName)
|
SeenApps.markSeen(this, snapshot.packageName)
|
||||||
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false)
|
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
AlertPolicySync.publishPlaybackState(this, false)
|
||||||
|
alertPlayback.stop()
|
||||||
writeExecutor.shutdown()
|
writeExecutor.shutdown()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
@@ -106,9 +133,13 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
imageBytes: (() -> ByteArray?)? = null,
|
imageBytes: (() -> ByteArray?)? = null,
|
||||||
) {
|
) {
|
||||||
if (!GroupSummaryPolicy.shouldLog(snapshot, action, captureSettings.logGroupSummaries)) return
|
if (!GroupSummaryPolicy.shouldLog(snapshot, action, captureSettings.logGroupSummaries)) return
|
||||||
if (!allows(loggingType, snapshot.packageName)) return
|
if (!loggingEnabled(loggingType, snapshot.packageName) ||
|
||||||
|
!NotificationRuleEvaluator.allows(appFilterStore.load(), snapshot.packageName)
|
||||||
|
) return
|
||||||
val appName = appName(snapshot.packageName)
|
val appName = appName(snapshot.packageName)
|
||||||
val retainImage = includeContents && snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
|
val textEnabled = includeContents && loggingEnabled(LoggingType.TEXT_CONTENT, snapshot.packageName)
|
||||||
|
val imageEnabled = includeContents && loggingEnabled(LoggingType.IMAGE_CONTENT, snapshot.packageName)
|
||||||
|
val retainImage = snapshot.hasImage && imageEnabled
|
||||||
val retainedImageBytes = if (retainImage) imageBytes?.invoke() else null
|
val retainedImageBytes = if (retainImage) imageBytes?.invoke() else null
|
||||||
val entry = NotificationLogEntry(
|
val entry = NotificationLogEntry(
|
||||||
recordedAtEpochMillis = System.currentTimeMillis(),
|
recordedAtEpochMillis = System.currentTimeMillis(),
|
||||||
@@ -116,8 +147,8 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
packageName = snapshot.packageName,
|
packageName = snapshot.packageName,
|
||||||
appName = appName,
|
appName = appName,
|
||||||
action = action,
|
action = action,
|
||||||
contents = if (includeContents) visibleContents(snapshot) else null,
|
contents = if (includeContents) visibleContents(snapshot, textEnabled, imageEnabled) else null,
|
||||||
previousContents = previousSnapshot?.let(::visibleContents),
|
previousContents = previousSnapshot?.let { visibleContents(it, textEnabled, imageEnabled) },
|
||||||
imageId = if (retainedImageBytes != null) java.util.UUID.randomUUID().toString() else null,
|
imageId = if (retainedImageBytes != null) java.util.UUID.randomUUID().toString() else null,
|
||||||
)
|
)
|
||||||
writeExecutor.execute {
|
writeExecutor.execute {
|
||||||
@@ -139,25 +170,33 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun visibleContents(snapshot: NotificationSnapshot): String? {
|
private fun visibleContents(snapshot: NotificationSnapshot, textEnabled: Boolean, imageEnabled: Boolean): String? {
|
||||||
val text = snapshot.textContents?.takeIf {
|
return listOfNotNull(
|
||||||
allows(LoggingType.TEXT_CONTENT, snapshot.packageName)
|
snapshot.textContents?.takeIf { textEnabled },
|
||||||
}
|
if (snapshot.hasImage && imageEnabled) "[image]" else null,
|
||||||
val image = snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
|
)
|
||||||
return listOfNotNull(text, if (image) "[image]" else null)
|
|
||||||
.joinToString("\n")
|
.joinToString("\n")
|
||||||
.take(MAX_CONTENT_CHARACTERS)
|
.take(MAX_CONTENT_CHARACTERS)
|
||||||
.ifEmpty { null }
|
.ifEmpty { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun allows(type: LoggingType, packageName: String): Boolean {
|
private fun loggingEnabled(type: LoggingType, packageName: String): Boolean {
|
||||||
val globalEnabled = ruleStore.ruleFor(type).enabled
|
val globalEnabled = ruleStore.ruleFor(type).enabled
|
||||||
val eventEnabled = if (type in LoggingType.eventTypes) {
|
return if (type in LoggingType.eventTypes) {
|
||||||
perAppEventSettings.isEnabled(packageName, type, globalEnabled)
|
perAppEventSettings.isEnabled(packageName, type, globalEnabled)
|
||||||
} else {
|
} else {
|
||||||
globalEnabled
|
globalEnabled
|
||||||
}
|
}
|
||||||
return eventEnabled && NotificationRuleEvaluator.allows(appFilterStore.load(), packageName)
|
}
|
||||||
|
|
||||||
|
private fun dispatchAlert(snapshot: NotificationSnapshot, source: AlertSource) {
|
||||||
|
val configuration = alertConfigurationStore.load()
|
||||||
|
alertQueueSettings = configuration.queueSettings
|
||||||
|
AlertNotificationDispatcher.dispatch(
|
||||||
|
configuration,
|
||||||
|
AlertEvent(snapshot.packageName, source, snapshot.alertTitle, snapshot.alertBody, snapshot.alertSender),
|
||||||
|
isRoutineUpdate = source == AlertSource.NOTIFICATION_UPDATE && snapshot.isRoutine,
|
||||||
|
).forEach(alertPlayback::enqueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun appName(packageName: String): String = try {
|
private fun appName(packageName: String): String = try {
|
||||||
+1
-1
@@ -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 {
|
||||||
+37
-6
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.capture
|
package se.ajpanton.notificationsmaster.capture
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -19,9 +19,18 @@ data class NotificationSnapshot(
|
|||||||
val hasImage: Boolean,
|
val hasImage: Boolean,
|
||||||
val isGroupSummary: Boolean,
|
val isGroupSummary: Boolean,
|
||||||
val isRoutine: Boolean,
|
val isRoutine: Boolean,
|
||||||
|
val alertTitle: String? = null,
|
||||||
|
val alertBody: String? = null,
|
||||||
|
val alertSender: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
object NotificationContents {
|
object NotificationContents {
|
||||||
|
data class AlertTextContents(
|
||||||
|
val title: String?,
|
||||||
|
val body: String?,
|
||||||
|
val sender: String?,
|
||||||
|
)
|
||||||
|
|
||||||
fun extract(notification: Notification): String? {
|
fun extract(notification: Notification): String? {
|
||||||
val extras = notification.extras ?: Bundle.EMPTY
|
val extras = notification.extras ?: Bundle.EMPTY
|
||||||
messagingContents(extras)?.let { return it }
|
messagingContents(extras)?.let { return it }
|
||||||
@@ -56,10 +65,12 @@ object NotificationContents {
|
|||||||
/** MessagingStyle also populates generic title/text fields with an alternate rendering. */
|
/** MessagingStyle also populates generic title/text fields with an alternate rendering. */
|
||||||
private fun messagingContents(extras: Bundle): String? {
|
private fun messagingContents(extras: Bundle): String? {
|
||||||
val parts = linkedSetOf<String>()
|
val parts = linkedSetOf<String>()
|
||||||
Notification.MessagingStyle.Message.getMessagesFromBundleArray(
|
listOf(Notification.EXTRA_HISTORIC_MESSAGES, Notification.EXTRA_MESSAGES).forEach { key ->
|
||||||
extras.getParcelableArray(Notification.EXTRA_MESSAGES, Bundle::class.java),
|
Notification.MessagingStyle.Message.getMessagesFromBundleArray(
|
||||||
).forEach { message ->
|
extras.getParcelableArray(key, Bundle::class.java),
|
||||||
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts)
|
).forEach { message ->
|
||||||
|
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
|
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
|
||||||
}
|
}
|
||||||
@@ -67,6 +78,7 @@ object NotificationContents {
|
|||||||
fun snapshot(sbn: StatusBarNotification): NotificationSnapshot {
|
fun snapshot(sbn: StatusBarNotification): NotificationSnapshot {
|
||||||
val notification = sbn.notification
|
val notification = sbn.notification
|
||||||
val extras = notification.extras
|
val extras = notification.extras
|
||||||
|
val alertContents = alertContents(notification)
|
||||||
return NotificationSnapshot(
|
return NotificationSnapshot(
|
||||||
key = sbn.key,
|
key = sbn.key,
|
||||||
packageName = sbn.packageName,
|
packageName = sbn.packageName,
|
||||||
@@ -76,10 +88,29 @@ object NotificationContents {
|
|||||||
} == true,
|
} == true,
|
||||||
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
|
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
|
||||||
isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
|
isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
|
||||||
extras?.containsKey(Notification.EXTRA_PROGRESS) == 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. */
|
/** Called only after the event has passed all capture filters. */
|
||||||
fun extractImageBytes(notification: Notification, context: Context): ByteArray? {
|
fun extractImageBytes(notification: Notification, context: Context): ByteArray? {
|
||||||
val extras = notification.extras ?: return null
|
val extras = notification.extras ?: return null
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.capture
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.SystemClock
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** Persists packages seen by the listener for the duration of the current boot. */
|
||||||
|
object SeenApps {
|
||||||
|
@Synchronized
|
||||||
|
fun markSeen(context: Context, packageName: String) {
|
||||||
|
val preferences = preferences(context)
|
||||||
|
resetAfterBoot(preferences)
|
||||||
|
val packages = preferences.getStringSet(KEY_PACKAGES, emptySet()).orEmpty()
|
||||||
|
if (packageName !in packages) {
|
||||||
|
preferences.edit().putStringSet(KEY_PACKAGES, packages + packageName).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun snapshot(context: Context): Set<String> {
|
||||||
|
val preferences = preferences(context)
|
||||||
|
resetAfterBoot(preferences)
|
||||||
|
return preferences.getStringSet(KEY_PACKAGES, emptySet()).orEmpty().toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetAfterBoot(preferences: android.content.SharedPreferences) {
|
||||||
|
val bootEpoch = System.currentTimeMillis() - SystemClock.elapsedRealtime()
|
||||||
|
if (abs(preferences.getLong(KEY_BOOT_EPOCH, -1) - bootEpoch) > 5_000) {
|
||||||
|
preferences.edit().putLong(KEY_BOOT_EPOCH, bootEpoch).remove(KEY_PACKAGES).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun preferences(context: Context) =
|
||||||
|
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private const val PREFERENCES = "seen_notification_apps"
|
||||||
|
private const val KEY_PACKAGES = "packages"
|
||||||
|
private const val KEY_BOOT_EPOCH = "boot_epoch"
|
||||||
|
}
|
||||||
+1
-1
@@ -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
-1
@@ -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
|
||||||
+12
-12
@@ -1,9 +1,9 @@
|
|||||||
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
|
||||||
import se.ajpanton.notificationlog.model.NotificationLogEntry
|
import se.ajpanton.notificationsmaster.model.NotificationLogEntry
|
||||||
import se.ajpanton.notificationlog.settings.StorageLimitsStore
|
import se.ajpanton.notificationsmaster.settings.StorageLimitsStore
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
import java.io.BufferedOutputStream
|
import java.io.BufferedOutputStream
|
||||||
import java.io.DataInputStream
|
import java.io.DataInputStream
|
||||||
@@ -80,10 +80,11 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
val chunks = chunkFiles()
|
val chunks = chunkFiles()
|
||||||
val newest = chunks.lastOrNull()
|
val newest = chunks.lastOrNull()
|
||||||
val current = newest?.let(::readChunk).orEmpty()
|
val current = newest?.let(::readChunk).orEmpty()
|
||||||
if (newest == null || encodedSize(current + entry) > MAX_CHUNK_PLAINTEXT_BYTES) {
|
val combined = NotificationLogEntryJson.encode(current + entry)
|
||||||
writeChunk(nextChunkFile(chunks), listOf(entry))
|
if (newest == null || combined.size <= MAX_CHUNK_PLAINTEXT_BYTES) {
|
||||||
|
writeChunk(newest ?: nextChunkFile(chunks), combined)
|
||||||
} else {
|
} else {
|
||||||
writeChunk(newest, current + entry)
|
writeChunk(nextChunkFile(chunks), NotificationLogEntryJson.encode(listOf(entry)))
|
||||||
}
|
}
|
||||||
enforceLimits()
|
enforceLimits()
|
||||||
}
|
}
|
||||||
@@ -98,7 +99,7 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
val entries = readChunk(chunk)
|
val entries = readChunk(chunk)
|
||||||
val entry = entries.firstOrNull { it.id == id } ?: return@forEach
|
val entry = entries.firstOrNull { it.id == id } ?: return@forEach
|
||||||
val retained = entries.filterNot { it.id == id }
|
val retained = entries.filterNot { it.id == id }
|
||||||
if (retained.isEmpty()) chunk.delete() else writeChunk(chunk, retained)
|
if (retained.isEmpty()) chunk.delete() else writeChunk(chunk, NotificationLogEntryJson.encode(retained))
|
||||||
entry.imageId?.let(::deleteImage)
|
entry.imageId?.let(::deleteImage)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -137,7 +138,8 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
while (totalBytes > limit && retained.isNotEmpty()) {
|
while (totalBytes > limit && retained.isNotEmpty()) {
|
||||||
retained.first().imageId?.let(::deleteImage)
|
retained.first().imageId?.let(::deleteImage)
|
||||||
retained = retained.drop(1)
|
retained = retained.drop(1)
|
||||||
if (retained.isEmpty()) oldest.delete() else writeChunk(oldest, retained)
|
if (retained.isEmpty()) oldest.delete()
|
||||||
|
else writeChunk(oldest, NotificationLogEntryJson.encode(retained))
|
||||||
totalBytes = chunkFiles().sumOf(File::length)
|
totalBytes = chunkFiles().sumOf(File::length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,8 +205,8 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeChunk(file: File, entries: List<NotificationLogEntry>) {
|
private fun writeChunk(file: File, encoded: ByteArray) {
|
||||||
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
|
val payload = cipher.encrypt(encoded)
|
||||||
val atomicFile = AtomicFile(file)
|
val atomicFile = AtomicFile(file)
|
||||||
val output = atomicFile.startWrite()
|
val output = atomicFile.startWrite()
|
||||||
try {
|
try {
|
||||||
@@ -228,8 +230,6 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
return ByteArray(length).also(stream::readFully)
|
return ByteArray(length).also(stream::readFully)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun encodedSize(entries: List<NotificationLogEntry>) = NotificationLogEntryJson.encode(entries).size
|
|
||||||
|
|
||||||
private fun clearChunksOnly() {
|
private fun clearChunksOnly() {
|
||||||
chunkFiles().forEach(File::delete)
|
chunkFiles().forEach(File::delete)
|
||||||
}
|
}
|
||||||
+1
-1
@@ -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
|
||||||
+3
-3
@@ -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)
|
||||||
|
}
|
||||||
+5
-5
@@ -1,10 +1,10 @@
|
|||||||
package se.ajpanton.notificationlog.export
|
package se.ajpanton.notificationsmaster.export
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import se.ajpanton.notificationlog.model.NotificationLogEntry
|
import se.ajpanton.notificationsmaster.model.NotificationLogEntry
|
||||||
import se.ajpanton.notificationlog.settings.LogField
|
import se.ajpanton.notificationsmaster.settings.LogField
|
||||||
import se.ajpanton.notificationlog.settings.LogViewSettings
|
import se.ajpanton.notificationsmaster.settings.LogViewSettings
|
||||||
import se.ajpanton.notificationlog.settings.TimestampFormatter
|
import se.ajpanton.notificationsmaster.settings.TimestampFormatter
|
||||||
|
|
||||||
object LogExporter {
|
object LogExporter {
|
||||||
fun csv(entries: List<NotificationLogEntry>, settings: LogViewSettings, context: Context? = null): String = (
|
fun csv(entries: List<NotificationLogEntry>, settings: LogViewSettings, context: Context? = null): String = (
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.model
|
package se.ajpanton.notificationsmaster.model
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.module
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertConfiguration
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationJson
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a bounded, one-way policy snapshot to the optional system-server
|
||||||
|
* module. Android enforces this APK's signature permission, and the broadcast
|
||||||
|
* is targeted at Android's system process rather than being visible to apps.
|
||||||
|
*/
|
||||||
|
internal object AlertPolicySync {
|
||||||
|
const val ACTION = "se.ajpanton.notificationsmaster.UPDATE_ALERT_POLICY"
|
||||||
|
const val PLAYBACK_STATE_ACTION = "se.ajpanton.notificationsmaster.UPDATE_ALERT_PLAYBACK_STATE"
|
||||||
|
const val EXTRA_CONFIGURATION = "configuration"
|
||||||
|
const val EXTRA_PLAYBACK_READY = "playback_ready"
|
||||||
|
const val PERMISSION = "se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY"
|
||||||
|
private const val SYSTEM_PACKAGE = "android"
|
||||||
|
private const val MAX_CONFIGURATION_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
fun publish(context: Context, configuration: AlertConfiguration) {
|
||||||
|
val payload = AlertConfigurationJson.encode(configuration)
|
||||||
|
if (payload.size > MAX_CONFIGURATION_BYTES) {
|
||||||
|
Log.w(TAG, "Alert policy is too large for the system cache; leaving native alerts unchanged")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
context.sendBroadcast(
|
||||||
|
Intent(ACTION)
|
||||||
|
.setPackage(SYSTEM_PACKAGE)
|
||||||
|
.putExtra(EXTRA_CONFIGURATION, payload),
|
||||||
|
)
|
||||||
|
Log.i(TAG, "Published alert policy snapshot")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publishPlaybackState(context: Context, ready: Boolean) {
|
||||||
|
context.sendBroadcast(
|
||||||
|
Intent(PLAYBACK_STATE_ACTION)
|
||||||
|
.setPackage(SYSTEM_PACKAGE)
|
||||||
|
.putExtra(EXTRA_PLAYBACK_READY, ready),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TAG = "NotificationsMaster"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-publishes process-local policies after boot, without starting a service. */
|
||||||
|
class AlertPolicyBootReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (Intent.ACTION_BOOT_COMPLETED != intent.action) return
|
||||||
|
AlertPolicySync.publish(context, se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore(context).load())
|
||||||
|
VisibilityPolicySync.publishStored(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.module
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.service.notification.StatusBarNotification
|
||||||
|
import android.util.Log
|
||||||
|
import io.github.libxposed.api.XposedInterface
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/** API 34 uses distinct mapper IDs for its unlocked and AOD icon containers. */
|
||||||
|
internal class Aosp34IconBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend {
|
||||||
|
private val controllers = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
|
||||||
|
private lateinit var updateStatusBarIcons: Method
|
||||||
|
private var updateAodNotificationIcons: Method? = null
|
||||||
|
private val blockedSurfaces = ConcurrentHashMap.newKeySet<NotificationSurface>()
|
||||||
|
|
||||||
|
override fun install(classLoader: ClassLoader): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT != 34) return false
|
||||||
|
return runCatching {
|
||||||
|
val controller = Class.forName(CONTROLLER, false, classLoader)
|
||||||
|
val mapper = Class.forName(ICON_MAPPER, false, classLoader)
|
||||||
|
val entry = Class.forName(NOTIFICATION_ENTRY, false, classLoader)
|
||||||
|
val classId = mapper.getDeclaredField("\$r8\$classId").also {
|
||||||
|
require(it.type == Int::class.javaPrimitiveType)
|
||||||
|
it.isAccessible = true
|
||||||
|
}
|
||||||
|
val sbn = entry.getDeclaredField("mSbn").also {
|
||||||
|
require(it.type == StatusBarNotification::class.java)
|
||||||
|
it.isAccessible = true
|
||||||
|
}
|
||||||
|
val apply = mapper.declaredMethods.singleOrNull {
|
||||||
|
it.name == "apply" && it.parameterTypes.contentEquals(arrayOf(Any::class.java))
|
||||||
|
} ?: error("Unlocked icon mapper method not found")
|
||||||
|
updateStatusBarIcons = controller.declaredMethods.singleOrNull {
|
||||||
|
it.name == "updateStatusBarIcons" && it.parameterTypes.isEmpty() && it.returnType == Void.TYPE
|
||||||
|
} ?: error("Unlocked icon refresh method not found")
|
||||||
|
updateAodNotificationIcons = controller.declaredMethods.singleOrNull {
|
||||||
|
it.name == "updateAodNotificationIcons" && it.parameterTypes.isEmpty() && it.returnType == Void.TYPE
|
||||||
|
}
|
||||||
|
apply.isAccessible = true
|
||||||
|
updateStatusBarIcons.isAccessible = true
|
||||||
|
updateAodNotificationIcons?.isAccessible = true
|
||||||
|
listOfNotNull(updateStatusBarIcons, updateAodNotificationIcons).forEach { refresh ->
|
||||||
|
framework.hook(refresh).intercept { chain ->
|
||||||
|
synchronized(controllers) { controllers += chain.thisObject }
|
||||||
|
chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
framework.hook(apply).intercept { chain ->
|
||||||
|
val surface = when (classId.getInt(chain.thisObject)) {
|
||||||
|
AOD_CLASS_ID -> if (updateAodNotificationIcons != null) {
|
||||||
|
NotificationSurface.AOD
|
||||||
|
} else return@intercept chain.proceed()
|
||||||
|
STATUS_BAR_CLASS_ID -> NotificationSurface.UNLOCKED_STATUSBAR
|
||||||
|
else -> return@intercept chain.proceed()
|
||||||
|
}
|
||||||
|
val notification = runCatching {
|
||||||
|
VisibilityNotificationExtractor.from(sbn.get(chain.args[0]) as StatusBarNotification)
|
||||||
|
}.getOrNull() ?: return@intercept chain.proceed()
|
||||||
|
if (!ProcessVisibilityPolicyCache.isBlocked(
|
||||||
|
notification,
|
||||||
|
surface,
|
||||||
|
)) return@intercept chain.proceed()
|
||||||
|
if (blockedSurfaces.add(surface)) {
|
||||||
|
Log.i(TAG, "Filtered an API 34 ${surface.name} notification icon")
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.onSuccess {
|
||||||
|
Log.i(TAG, "Installed AOSP API 34 unlocked notification-icon backend")
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "AOSP API 34 unlocked icon shape is unsupported; leaving icons unchanged", it)
|
||||||
|
}.isSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPolicyChanged() {
|
||||||
|
blockedSurfaces.clear()
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
val current = synchronized(controllers) { controllers.toList() }
|
||||||
|
current.forEach { controller ->
|
||||||
|
runCatching {
|
||||||
|
updateStatusBarIcons.invoke(controller)
|
||||||
|
updateAodNotificationIcons?.invoke(controller)
|
||||||
|
}.onFailure { Log.w(TAG, "Could not refresh API 34 notification icons", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "NotificationsMaster"
|
||||||
|
const val AOD_CLASS_ID = 0
|
||||||
|
const val STATUS_BAR_CLASS_ID = 1
|
||||||
|
const val CONTROLLER = "com.android.systemui.statusbar.phone.NotificationIconAreaController"
|
||||||
|
const val ICON_MAPPER = "$CONTROLLER\$\$ExternalSyntheticLambda2"
|
||||||
|
const val NOTIFICATION_ENTRY = "com.android.systemui.statusbar.notification.collection.NotificationEntry"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.module
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.service.notification.StatusBarNotification
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import io.github.libxposed.api.XposedInterface
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
|
||||||
|
/** Filters AOSP notification rows for the collapsed lockscreen or notification drawer. */
|
||||||
|
internal class AospLockscreenBackend(
|
||||||
|
private val framework: XposedInterface,
|
||||||
|
) : VisibilitySurfaceBackend {
|
||||||
|
private val stacks = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
|
||||||
|
private val shelves = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
|
||||||
|
private val hiddenRows = WeakHashMap<View, RowState>()
|
||||||
|
private val groupOrders = WeakHashMap<View, List<View>>()
|
||||||
|
private lateinit var onKeyguard: Method
|
||||||
|
private var stackExpanded: Field? = null
|
||||||
|
private lateinit var entryForRow: (View) -> Any?
|
||||||
|
private lateinit var sbnForEntry: (Any) -> StatusBarNotification?
|
||||||
|
private lateinit var attachedChildrenForRow: (View) -> List<View>
|
||||||
|
private lateinit var reorderChildren: (View, List<View>) -> Unit
|
||||||
|
private lateinit var setGroupCount: (View, Int) -> Unit
|
||||||
|
private lateinit var calculateShelfIcons: Method
|
||||||
|
private lateinit var shelfNotification: Field
|
||||||
|
private lateinit var shelfIconStates: Field
|
||||||
|
private lateinit var iconState: IconStateAccess
|
||||||
|
@Volatile private var blockedObserved = false
|
||||||
|
|
||||||
|
override fun install(classLoader: ClassLoader): Boolean {
|
||||||
|
if (Build.MANUFACTURER.equals("samsung", ignoreCase = true)) return false
|
||||||
|
return runCatching {
|
||||||
|
val stackClass = Class.forName(STACK, false, classLoader)
|
||||||
|
val rowClass = Class.forName(ROW, false, classLoader)
|
||||||
|
onKeyguard = stackClass.declaredMethods.singleOrNull {
|
||||||
|
it.name == "onKeyguard" && it.parameterCount == 0 &&
|
||||||
|
it.returnType == Boolean::class.javaPrimitiveType
|
||||||
|
}?.also { it.isAccessible = true }
|
||||||
|
?: error("AOSP lockscreen state method not found")
|
||||||
|
stackExpanded = findField(stackClass, "mIsExpanded")
|
||||||
|
prepareNotificationAccess(rowClass)
|
||||||
|
installShelfFilter(classLoader)
|
||||||
|
|
||||||
|
stackClass.declaredMethods.filter { it.name == "onViewAdded" }.forEach { method ->
|
||||||
|
method.isAccessible = true
|
||||||
|
framework.hook(method).intercept { chain ->
|
||||||
|
val result = chain.proceed()
|
||||||
|
(chain.thisObject as? ViewGroup)?.let { stack ->
|
||||||
|
track(stack)
|
||||||
|
(chain.args.firstOrNull() as? View)?.let { applyRow(stack, it) }
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stackClass.declaredMethods.filter { it.name == "onViewRemoved" }.forEach { method ->
|
||||||
|
method.isAccessible = true
|
||||||
|
framework.hook(method).intercept { chain ->
|
||||||
|
(chain.args.firstOrNull() as? View)?.let(::restoreTree)
|
||||||
|
chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stackClass.declaredMethods.filter { it.name == "onMeasure" }.forEach { method ->
|
||||||
|
method.isAccessible = true
|
||||||
|
framework.hook(method).intercept { chain ->
|
||||||
|
(chain.thisObject as? ViewGroup)?.let {
|
||||||
|
track(it)
|
||||||
|
applyStack(it)
|
||||||
|
}
|
||||||
|
chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stackClass.declaredMethods.filter {
|
||||||
|
it.name == "setStatusBarState" || it.name == "setIsExpanded"
|
||||||
|
}.forEach { method ->
|
||||||
|
method.isAccessible = true
|
||||||
|
framework.hook(method).intercept { chain ->
|
||||||
|
val result = chain.proceed()
|
||||||
|
(chain.thisObject as? ViewGroup)?.let {
|
||||||
|
track(it)
|
||||||
|
applyStack(it)
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onSuccess {
|
||||||
|
Log.i(TAG, "Installed AOSP lockscreen/drawer notification backend")
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "AOSP lockscreen shape is unsupported; leaving rows unchanged", it)
|
||||||
|
}.isSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPolicyChanged() {
|
||||||
|
blockedObserved = false
|
||||||
|
synchronized(stacks) { stacks.toList() }.forEach(::applyStack)
|
||||||
|
synchronized(shelves) { shelves.toList() }.forEach { shelf ->
|
||||||
|
runCatching { calculateShelfIcons.invoke(shelf) }
|
||||||
|
.onFailure { Log.w(TAG, "Could not refresh AOSP lockscreen shelf", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareNotificationAccess(rowClass: Class<*>) {
|
||||||
|
val entryMethod = rowClass.methods.firstOrNull { it.name == "getEntry" && it.parameterCount == 0 }
|
||||||
|
val entryField = findField(rowClass, "mEntry", "entry")
|
||||||
|
require(entryMethod != null || entryField != null) { "AOSP notification row entry is unavailable" }
|
||||||
|
entryForRow = memberAccessor(entryMethod, entryField)
|
||||||
|
|
||||||
|
val entryClass = entryMethod?.returnType ?: entryField!!.type
|
||||||
|
val sbnMethod = entryClass.methods.firstOrNull { it.name == "getSbn" && it.parameterCount == 0 }
|
||||||
|
val sbnField = findField(entryClass, "mSbn", "sbn", "mStatusBarNotification")
|
||||||
|
require(sbnMethod != null || sbnField != null) { "AOSP notification entry SBN is unavailable" }
|
||||||
|
val accessor = memberAccessor(sbnMethod, sbnField)
|
||||||
|
sbnForEntry = { accessor(it) as? StatusBarNotification }
|
||||||
|
val attachedChildren = rowClass.methods.firstOrNull {
|
||||||
|
it.name == "getAttachedChildren" && it.parameterCount == 0
|
||||||
|
}?.also { it.isAccessible = true }
|
||||||
|
attachedChildrenForRow = { row ->
|
||||||
|
(attachedChildren?.invoke(row) as? List<*>)?.filterIsInstance<View>().orEmpty()
|
||||||
|
}
|
||||||
|
reorderChildren = { row, ordered ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(attachedChildren?.invoke(row) as? MutableList<Any?>)?.let {
|
||||||
|
it.clear()
|
||||||
|
it.addAll(ordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val childrenContainer = requireField(rowClass, "mChildrenContainer")
|
||||||
|
val untruncatedCount = requireField(childrenContainer.type, "mUntruncatedChildCount")
|
||||||
|
setGroupCount = { row, count ->
|
||||||
|
childrenContainer.get(row)?.let { untruncatedCount.setInt(it, count) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installShelfFilter(classLoader: ClassLoader) {
|
||||||
|
val iconContainer = Class.forName(ICON_CONTAINER, false, classLoader)
|
||||||
|
val iconView = Class.forName(STATUS_BAR_ICON_VIEW, false, classLoader)
|
||||||
|
iconState = IconStateAccess(Class.forName(ICON_STATE, false, classLoader))
|
||||||
|
calculateShelfIcons = iconContainer.declaredMethods.singleOrNull {
|
||||||
|
it.name == "calculateIconXTranslations" && it.parameterCount == 0
|
||||||
|
}?.also { it.isAccessible = true } ?: error("AOSP shelf icon layout method not found")
|
||||||
|
shelfNotification = requireField(iconView, "mNotification")
|
||||||
|
shelfIconStates = requireField(iconContainer, "mIconStates")
|
||||||
|
|
||||||
|
framework.hook(calculateShelfIcons).intercept { chain ->
|
||||||
|
val container = chain.thisObject as? ViewGroup
|
||||||
|
?: return@intercept chain.proceed()
|
||||||
|
if (container.parent?.javaClass?.name != SHELF) return@intercept chain.proceed()
|
||||||
|
synchronized(shelves) { shelves += container }
|
||||||
|
val surface = surfaceFor(container) ?: return@intercept chain.proceed()
|
||||||
|
val children = List(container.childCount, container::getChildAt)
|
||||||
|
val states = shelfIconStates.get(container) as? Map<*, *>
|
||||||
|
?: return@intercept chain.proceed()
|
||||||
|
val blocked = children.filter { isBlockedShelfIcon(it, surface) }
|
||||||
|
blocked.forEach { states[it]?.let(iconState::hide) }
|
||||||
|
children.filterNot(blocked::contains).forEach(container::bringChildToFront)
|
||||||
|
val result = try {
|
||||||
|
chain.proceed()
|
||||||
|
} finally {
|
||||||
|
children.forEach(container::bringChildToFront)
|
||||||
|
}
|
||||||
|
blocked.forEach { states[it]?.let(iconState::hide) }
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun track(stack: ViewGroup) {
|
||||||
|
synchronized(stacks) { stacks += stack }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyStack(stack: ViewGroup) {
|
||||||
|
val surface = surfaceFor(stack)
|
||||||
|
repeat(stack.childCount) { applyRow(stack.getChildAt(it), surface) }
|
||||||
|
stack.requestLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyRow(stack: ViewGroup, row: View) = applyRow(row, surfaceFor(stack))
|
||||||
|
|
||||||
|
private fun applyRow(row: View, surface: NotificationSurface?) {
|
||||||
|
if (row.javaClass.name != ROW) return
|
||||||
|
if (surface == null) {
|
||||||
|
restoreTree(row)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
restoreGroupOrder(row)
|
||||||
|
val children = attachedChildrenForRow(row)
|
||||||
|
val parentBlocked = isBlocked(row, surface)
|
||||||
|
val blockedChildren = children.map { it to isBlocked(it, surface) }
|
||||||
|
if (blockedChildren.any { it.second }) {
|
||||||
|
groupOrders.putIfAbsent(row, children)
|
||||||
|
val ordered = blockedChildren.partition { !it.second }.let { it.first + it.second }
|
||||||
|
reorderChildren(row, ordered.map { it.first })
|
||||||
|
setGroupCount(row, blockedChildren.count { !it.second })
|
||||||
|
}
|
||||||
|
blockedChildren.forEach { (child, blocked) ->
|
||||||
|
if (blocked) hideRow(child) else restoreRow(child)
|
||||||
|
}
|
||||||
|
if (parentBlocked && blockedChildren.all { it.second }) hideRow(row) else restoreRow(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBlocked(row: View, surface: NotificationSurface): Boolean {
|
||||||
|
val sbn = runCatching { entryForRow(row)?.let(sbnForEntry) }.getOrNull() ?: return false
|
||||||
|
return isBlocked(sbn, surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBlockedShelfIcon(icon: View, surface: NotificationSurface): Boolean {
|
||||||
|
if (icon.javaClass.name != STATUS_BAR_ICON_VIEW) return false
|
||||||
|
val sbn = runCatching { shelfNotification.get(icon) as? StatusBarNotification }.getOrNull()
|
||||||
|
?: return false
|
||||||
|
val row = rowForKey(icon, sbn.key)
|
||||||
|
return row?.let {
|
||||||
|
isBlocked(it, surface) && attachedChildrenForRow(it).none { child -> !isBlocked(child, surface) }
|
||||||
|
} ?: isBlocked(sbn, surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rowForKey(view: View, key: String): View? {
|
||||||
|
val root = view.rootView
|
||||||
|
return synchronized(stacks) { stacks.toList() }
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.rootView === root }
|
||||||
|
.flatMap { stack -> (0 until stack.childCount).asSequence().map(stack::getChildAt) }
|
||||||
|
.firstOrNull { row ->
|
||||||
|
row.javaClass.name == ROW &&
|
||||||
|
runCatching { entryForRow(row)?.let(sbnForEntry)?.key == key }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBlocked(sbn: StatusBarNotification, surface: NotificationSurface): Boolean =
|
||||||
|
ProcessVisibilityPolicyCache.isBlocked(
|
||||||
|
VisibilityNotificationExtractor.from(sbn),
|
||||||
|
surface,
|
||||||
|
).also { blocked ->
|
||||||
|
if (blocked && !blockedObserved) {
|
||||||
|
blockedObserved = true
|
||||||
|
Log.i(TAG, "Filtered an AOSP notification from $surface")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun surfaceFor(view: View): NotificationSurface? {
|
||||||
|
val root = view.rootView
|
||||||
|
val stack = synchronized(stacks) { stacks.firstOrNull { it.rootView === root } } ?: return null
|
||||||
|
return runCatching {
|
||||||
|
when {
|
||||||
|
onKeyguard.invoke(stack) as Boolean -> NotificationSurface.LOCKSCREEN_COLLAPSED
|
||||||
|
stackExpanded?.getBoolean(stack) == true -> NotificationSurface.DRAWER
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideRow(row: View) {
|
||||||
|
if (row.visibility == View.GONE && row.alpha == 0f) return
|
||||||
|
hiddenRows.putIfAbsent(row, RowState(row.visibility, row.alpha))
|
||||||
|
row.visibility = View.GONE
|
||||||
|
row.alpha = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreRow(row: View) {
|
||||||
|
val state = hiddenRows.remove(row) ?: return
|
||||||
|
row.visibility = state.visibility
|
||||||
|
row.alpha = state.alpha
|
||||||
|
row.requestLayout()
|
||||||
|
(row.parent as? View)?.requestLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreTree(row: View) {
|
||||||
|
restoreGroupOrder(row)
|
||||||
|
if (row.javaClass.name == ROW) attachedChildrenForRow(row).forEach(::restoreRow)
|
||||||
|
restoreRow(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreGroupOrder(row: View) {
|
||||||
|
val saved = groupOrders.remove(row) ?: return
|
||||||
|
val current = attachedChildrenForRow(row)
|
||||||
|
reorderChildren(row, saved.filter(current::contains) + current.filterNot(saved::contains))
|
||||||
|
setGroupCount(row, current.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RowState(val visibility: Int, val alpha: Float)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "NotificationsMaster"
|
||||||
|
const val STACK = "com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout"
|
||||||
|
const val ROW = "com.android.systemui.statusbar.notification.row.ExpandableNotificationRow"
|
||||||
|
const val SHELF = "com.android.systemui.statusbar.NotificationShelf"
|
||||||
|
const val ICON_CONTAINER = "com.android.systemui.statusbar.phone.NotificationIconContainer"
|
||||||
|
const val ICON_STATE = "$ICON_CONTAINER\$IconState"
|
||||||
|
const val STATUS_BAR_ICON_VIEW = "com.android.systemui.statusbar.StatusBarIconView"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.module
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.service.notification.StatusBarNotification
|
||||||
|
import android.util.Log
|
||||||
|
import io.github.libxposed.api.XposedInterface
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.VisibilityNotification
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.lang.reflect.Proxy
|
||||||
|
import java.util.LinkedHashSet
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/** Filters the modern unlocked and AOD icon flows; the notification/shade model is untouched. */
|
||||||
|
internal class AospModernIconBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend {
|
||||||
|
private val metadata = ConcurrentHashMap<String, VisibilityNotification>()
|
||||||
|
private val activeKeys = ConcurrentHashMap<NotificationSurface, Set<String>>()
|
||||||
|
@Volatile private var policyChanges: Any? = null
|
||||||
|
private var policyRevision = 0L
|
||||||
|
private val lastFilterSignatures = ConcurrentHashMap<NotificationSurface, Long>()
|
||||||
|
@Volatile private var captureFailureLogged = false
|
||||||
|
|
||||||
|
override fun install(classLoader: ClassLoader): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT !in 35..36) return false
|
||||||
|
return runCatching {
|
||||||
|
val entryClass = Class.forName(NOTIFICATION_ENTRY, false, classLoader)
|
||||||
|
val modelClass = Class.forName(ACTIVE_NOTIFICATION_MODEL, false, classLoader)
|
||||||
|
val modelAccesses = buildList {
|
||||||
|
add(ModelAccess(
|
||||||
|
modelClass,
|
||||||
|
modelClass.requiredField("key", String::class.java),
|
||||||
|
modelClass.requiredField("packageName", String::class.java),
|
||||||
|
))
|
||||||
|
runCatching { Class.forName(ACTIVE_NOTIFICATION_ICON_MODEL, false, classLoader) }
|
||||||
|
.getOrNull()?.let { iconModel ->
|
||||||
|
add(ModelAccess(iconModel, iconModel.requiredField("notifKey", String::class.java), null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
installMetadataCapture(classLoader, entryClass, modelClass)
|
||||||
|
installFlowRefresh(classLoader)
|
||||||
|
installStatusBarFilter(classLoader, modelAccesses)
|
||||||
|
installAodFilter(classLoader, modelAccesses)
|
||||||
|
}.onSuccess {
|
||||||
|
Log.i(TAG, "Installed AOSP API ${Build.VERSION.SDK_INT} unlocked notification-icon backend")
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "AOSP API ${Build.VERSION.SDK_INT} unlocked icon shape is unsupported; leaving icons unchanged", it)
|
||||||
|
}.isSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized override fun onPolicyChanged() {
|
||||||
|
val signal = policyChanges ?: return
|
||||||
|
policyRevision += 1
|
||||||
|
runCatching {
|
||||||
|
signal.javaClass.methods.single { it.name == "setValue" && it.parameterTypes.size == 1 }
|
||||||
|
.invoke(signal, policyRevision)
|
||||||
|
}.onFailure { Log.w(TAG, "Could not refresh unlocked notification icons", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installMetadataCapture(classLoader: ClassLoader, entryClass: Class<*>, modelClass: Class<*>) {
|
||||||
|
val builder = Class.forName(ACTIVE_STORE_BUILDER, false, classLoader)
|
||||||
|
val method = builder.declaredMethods.singleOrNull {
|
||||||
|
it.name == "toModel" && it.returnType == modelClass && it.parameterTypes.contentEquals(arrayOf(entryClass))
|
||||||
|
} ?: error("Active notification conversion method not found")
|
||||||
|
val sbnField = entryClass.requiredField("mSbn", StatusBarNotification::class.java)
|
||||||
|
method.isAccessible = true
|
||||||
|
framework.hook(method).intercept { chain ->
|
||||||
|
val result = chain.proceed()
|
||||||
|
runCatching { sbnField.get(chain.args[0]) as StatusBarNotification }
|
||||||
|
.map(VisibilityNotificationExtractor::from)
|
||||||
|
.onSuccess { metadata[it.key] = it }
|
||||||
|
.onFailure {
|
||||||
|
if (!captureFailureLogged) {
|
||||||
|
captureFailureLogged = true
|
||||||
|
Log.w(TAG, "Notification metadata capture failed; text exceptions will fail open", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installFlowRefresh(classLoader: ClassLoader) {
|
||||||
|
val interactor = Class.forName(STATUS_BAR_INTERACTOR, false, classLoader)
|
||||||
|
val flowField = interactor.requiredField("statusBarNotifs", null)
|
||||||
|
require(flowField.type.name == "kotlinx.coroutines.flow.Flow")
|
||||||
|
val flowClass = Class.forName("kotlinx.coroutines.flow.Flow", false, classLoader)
|
||||||
|
val functionClass = Class.forName("kotlin.jvm.functions.Function3", false, classLoader)
|
||||||
|
val signal = Class.forName("kotlinx.coroutines.flow.StateFlowKt", false, classLoader)
|
||||||
|
.declaredMethods.single { it.name == "MutableStateFlow" && it.parameterTypes.size == 1 }
|
||||||
|
.invoke(null, policyRevision)
|
||||||
|
val combineMethod = Class.forName("kotlinx.coroutines.flow.FlowKt", false, classLoader)
|
||||||
|
.methods.singleOrNull {
|
||||||
|
it.name == "combine" && it.parameterTypes.map(Class<*>::getName) == listOf(
|
||||||
|
flowClass.name,
|
||||||
|
flowClass.name,
|
||||||
|
functionClass.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val transformMethod = if (combineMethod == null) {
|
||||||
|
val function4 = Class.forName("kotlin.jvm.functions.Function4", false, classLoader)
|
||||||
|
Class.forName("kotlinx.coroutines.flow.FlowKt", false, classLoader).methods.singleOrNull {
|
||||||
|
it.name == "combineTransform" && it.parameterTypes.map(Class<*>::getName) == listOf(
|
||||||
|
flowClass.name,
|
||||||
|
flowClass.name,
|
||||||
|
function4.name,
|
||||||
|
)
|
||||||
|
}?.let { method ->
|
||||||
|
val collector = Class.forName("kotlinx.coroutines.flow.FlowCollector", false, classLoader)
|
||||||
|
val continuation = Class.forName("kotlin.coroutines.Continuation", false, classLoader)
|
||||||
|
val emit = collector.getMethod("emit", Any::class.java, continuation)
|
||||||
|
method to Proxy.newProxyInstance(classLoader, arrayOf(function4)) { proxy, called, args ->
|
||||||
|
when (called.name) {
|
||||||
|
"invoke" -> emit.invoke(args!![0], args[1], args[3])
|
||||||
|
"toString" -> "NotificationsMasterVisibilityTransform"
|
||||||
|
"hashCode" -> System.identityHashCode(proxy)
|
||||||
|
"equals" -> proxy === args?.get(0)
|
||||||
|
else -> error("Unexpected Function4 method ${called.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val combineConstructor = if (combineMethod == null && transformMethod == null) {
|
||||||
|
Class.forName(TWO_FLOW_COMBINE, false, classLoader).declaredConstructors.singleOrNull {
|
||||||
|
it.parameterTypes.map(Class<*>::getName) == listOf(
|
||||||
|
flowClass.name,
|
||||||
|
flowClass.name,
|
||||||
|
functionClass.name,
|
||||||
|
)
|
||||||
|
}?.also { it.isAccessible = true }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
require(combineMethod != null || transformMethod != null || combineConstructor != null) {
|
||||||
|
"Two-flow combine implementation not found"
|
||||||
|
}
|
||||||
|
val keepUpstreamValue = Proxy.newProxyInstance(classLoader, arrayOf(functionClass)) { proxy, method, args ->
|
||||||
|
when (method.name) {
|
||||||
|
"invoke" -> args?.get(0)
|
||||||
|
"toString" -> "NotificationsMasterVisibilityRefresh"
|
||||||
|
"hashCode" -> System.identityHashCode(proxy)
|
||||||
|
"equals" -> proxy === args?.get(0)
|
||||||
|
else -> error("Unexpected Function3 method ${method.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun refreshed(upstream: Any?) = combineMethod?.invoke(null, upstream, signal, keepUpstreamValue)
|
||||||
|
?: transformMethod?.let { (method, transform) -> method.invoke(null, upstream, signal, transform) }
|
||||||
|
?: combineConstructor!!.newInstance(upstream, signal, keepUpstreamValue)
|
||||||
|
policyChanges = signal
|
||||||
|
val constructor = interactor.declaredConstructors.singleOrNull {
|
||||||
|
it.parameterTypes.map { type -> type.name } == listOf(
|
||||||
|
"kotlin.coroutines.CoroutineContext",
|
||||||
|
"com.android.systemui.statusbar.notification.icon.domain.interactor.NotificationIconsInteractor",
|
||||||
|
"com.android.systemui.statusbar.data.repository.NotificationListenerSettingsRepository",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val (field, constructors) = if (constructor != null) {
|
||||||
|
flowField to listOf(constructor)
|
||||||
|
} else {
|
||||||
|
val viewModel = Class.forName(STATUS_BAR_VIEW_MODEL, false, classLoader)
|
||||||
|
viewModel.requiredField("icons", null).also {
|
||||||
|
require(flowClass.isAssignableFrom(it.type))
|
||||||
|
} to viewModel.declaredConstructors.toList()
|
||||||
|
}
|
||||||
|
require(constructors.isNotEmpty()) { "Status-bar flow owner has no constructors" }
|
||||||
|
constructors.forEach { target ->
|
||||||
|
target.isAccessible = true
|
||||||
|
framework.hook(target).intercept { chain ->
|
||||||
|
val result = chain.proceed()
|
||||||
|
runCatching {
|
||||||
|
field.set(chain.thisObject, refreshed(field.get(chain.thisObject)))
|
||||||
|
}.onFailure { Log.w(TAG, "Could not attach visibility refresh flow; leaving icons unchanged", it) }
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
val aod = Class.forName(AOD_VIEW_MODEL, false, classLoader)
|
||||||
|
val icons = aod.requiredField("icons", null).also { require(flowClass.isAssignableFrom(it.type)) }
|
||||||
|
require(aod.declaredConstructors.isNotEmpty()) { "AOD view model has no constructors" }
|
||||||
|
aod.declaredConstructors.forEach { constructor ->
|
||||||
|
constructor.isAccessible = true
|
||||||
|
framework.hook(constructor).intercept { chain ->
|
||||||
|
val result = chain.proceed()
|
||||||
|
icons.set(chain.thisObject, refreshed(icons.get(chain.thisObject)))
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { Log.i(TAG, "AOD icon refresh flow is unavailable") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installStatusBarFilter(
|
||||||
|
classLoader: ClassLoader,
|
||||||
|
modelAccesses: List<ModelAccess>,
|
||||||
|
) {
|
||||||
|
val collector = statusBarMapCollectors().firstNotNullOfOrNull { name ->
|
||||||
|
runCatching { Class.forName(name, false, classLoader) }.getOrNull()
|
||||||
|
} ?: error("Status-bar icon collector class not found")
|
||||||
|
val emit = collector.declaredMethods.singleOrNull(::isEmitMethod)
|
||||||
|
?: error("Status-bar icon collector method not found")
|
||||||
|
emit.isAccessible = true
|
||||||
|
framework.hook(emit).intercept { chain ->
|
||||||
|
val original = chain.args[0]
|
||||||
|
val filtered = filter(original, modelAccesses, NotificationSurface.UNLOCKED_STATUSBAR)
|
||||||
|
if (filtered === original) chain.proceed() else chain.proceed(arrayOf(filtered, chain.args[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installAodFilter(classLoader: ClassLoader, modelAccesses: List<ModelAccess>) {
|
||||||
|
runCatching {
|
||||||
|
val collector = Class.forName(AOD_MAP_COLLECTOR, false, classLoader)
|
||||||
|
val emit = collector.declaredMethods.singleOrNull(::isEmitMethod)
|
||||||
|
?: error("AOD icon collector method not found")
|
||||||
|
emit.isAccessible = true
|
||||||
|
framework.hook(emit).intercept { chain ->
|
||||||
|
val original = chain.args[0]
|
||||||
|
val filtered = filter(original, modelAccesses, NotificationSurface.AOD)
|
||||||
|
if (filtered === original) chain.proceed() else chain.proceed(arrayOf(filtered, chain.args[1]))
|
||||||
|
}
|
||||||
|
}.onSuccess {
|
||||||
|
Log.i(TAG, "Installed AOSP AOD notification-icon backend")
|
||||||
|
}.onFailure {
|
||||||
|
Log.i(TAG, "AOSP AOD notification-icon boundary is unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filter(
|
||||||
|
value: Any?,
|
||||||
|
modelAccesses: List<ModelAccess>,
|
||||||
|
surface: NotificationSurface,
|
||||||
|
): Any? {
|
||||||
|
val entries = value as? Set<*> ?: return value
|
||||||
|
if (entries.any { model -> model != null && modelAccesses.none { it.type.isInstance(model) } }) return value
|
||||||
|
fun access(model: Any) = modelAccesses.first { it.type.isInstance(model) }
|
||||||
|
val surfaceKeys = entries.mapNotNull { model ->
|
||||||
|
model?.let { access(it).key.get(it) as? String }
|
||||||
|
}.toSet()
|
||||||
|
activeKeys[surface] = surfaceKeys
|
||||||
|
metadata.keys.retainAll(activeKeys.values.flatten().toSet())
|
||||||
|
var changed = false
|
||||||
|
var missingMetadata = 0
|
||||||
|
val filtered = entries.filterTo(LinkedHashSet(entries.size)) { model ->
|
||||||
|
if (model == null) return@filterTo true
|
||||||
|
val fields = access(model)
|
||||||
|
val key = fields.key.get(model) as? String ?: return@filterTo true
|
||||||
|
val captured = metadata[key]
|
||||||
|
val packageName = fields.packageName?.get(model) as? String
|
||||||
|
?: captured?.packageName
|
||||||
|
?: key.split('|', limit = 3).getOrNull(1)
|
||||||
|
?: return@filterTo true
|
||||||
|
val hasExceptions = ProcessVisibilityPolicyCache.hasExceptions(packageName)
|
||||||
|
if (hasExceptions && captured == null) missingMetadata += 1
|
||||||
|
val notification = captured ?: VisibilityNotification(packageName, key)
|
||||||
|
val blocked = (!hasExceptions || captured != null) &&
|
||||||
|
ProcessVisibilityPolicyCache.isBlocked(notification, surface)
|
||||||
|
changed = changed || blocked
|
||||||
|
!blocked
|
||||||
|
}
|
||||||
|
val hidden = entries.size - filtered.size
|
||||||
|
val signature = ((entries.size * 31L + hidden) * 31L + missingMetadata)
|
||||||
|
if (lastFilterSignatures.put(surface, signature) != signature) {
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"${surface.name} notification icons: input=${entries.size}, hidden=$hidden, " +
|
||||||
|
"missingMetadata=$missingMetadata",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return if (changed) filtered else value
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ModelAccess(val type: Class<*>, val key: Field, val packageName: Field?)
|
||||||
|
|
||||||
|
private fun Class<*>.requiredField(name: String, type: Class<*>?): Field =
|
||||||
|
getDeclaredField(name).also {
|
||||||
|
require(type == null || it.type == type) { "Unexpected $name field type" }
|
||||||
|
it.isAccessible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isEmitMethod(method: Method) = method.name == "emit" &&
|
||||||
|
method.parameterTypes.size == 2 && method.parameterTypes[1].name == "kotlin.coroutines.Continuation"
|
||||||
|
|
||||||
|
private fun statusBarMapCollectors() =
|
||||||
|
(if (Build.VERSION.SDK_INT == 35) listOf("2\$2", "1\$2") else listOf("1\$2"))
|
||||||
|
.map { STATUS_BAR_MAP_COLLECTOR_PREFIX + it }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "NotificationsMaster"
|
||||||
|
const val NOTIFICATION_ENTRY = "com.android.systemui.statusbar.notification.collection.NotificationEntry"
|
||||||
|
const val ACTIVE_NOTIFICATION_MODEL =
|
||||||
|
"com.android.systemui.statusbar.notification.shared.ActiveNotificationModel"
|
||||||
|
const val ACTIVE_NOTIFICATION_ICON_MODEL =
|
||||||
|
"com.android.systemui.statusbar.notification.icon.domain.interactor.ActiveNotificationIconModel"
|
||||||
|
const val ACTIVE_STORE_BUILDER =
|
||||||
|
"com.android.systemui.statusbar.notification.domain.interactor.ActiveNotificationsStoreBuilder"
|
||||||
|
const val STATUS_BAR_INTERACTOR =
|
||||||
|
"com.android.systemui.statusbar.notification.icon.domain.interactor.StatusBarNotificationIconsInteractor"
|
||||||
|
const val STATUS_BAR_VIEW_MODEL =
|
||||||
|
"com.android.systemui.statusbar.notification.icon.ui.viewmodel.NotificationIconContainerStatusBarViewModel"
|
||||||
|
const val AOD_VIEW_MODEL =
|
||||||
|
"com.android.systemui.statusbar.notification.icon.ui.viewmodel.NotificationIconContainerAlwaysOnDisplayViewModel"
|
||||||
|
const val AOD_MAP_COLLECTOR = "$AOD_VIEW_MODEL\$special\$\$inlined\$map\$1\$2"
|
||||||
|
const val STATUS_BAR_MAP_COLLECTOR_PREFIX =
|
||||||
|
"com.android.systemui.statusbar.notification.icon.ui.viewmodel." +
|
||||||
|
"NotificationIconContainerStatusBarViewModel\$special\$\$inlined\$map\$"
|
||||||
|
const val TWO_FLOW_COMBINE =
|
||||||
|
"kotlinx.coroutines.flow.FlowKt__ZipKt\$combine\$\$inlined\$unsafeFlow\$1"
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -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>()
|
||||||
|
}
|
||||||
+376
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -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()
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.module
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.service.notification.StatusBarNotification
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.VisibilityNotification
|
||||||
|
|
||||||
|
internal object VisibilityNotificationExtractor {
|
||||||
|
fun from(sbn: StatusBarNotification): VisibilityNotification {
|
||||||
|
val notification = sbn.notification
|
||||||
|
val extras = notification.extras ?: Bundle.EMPTY
|
||||||
|
val messages = listOf(Notification.EXTRA_HISTORIC_MESSAGES, Notification.EXTRA_MESSAGES).flatMap { key ->
|
||||||
|
Notification.MessagingStyle.Message.getMessagesFromBundleArray(
|
||||||
|
extras.getParcelableArray(key, Bundle::class.java),
|
||||||
|
).map { message ->
|
||||||
|
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return VisibilityNotification(
|
||||||
|
packageName = sbn.packageName,
|
||||||
|
key = sbn.key,
|
||||||
|
title = extras.text(Notification.EXTRA_TITLE),
|
||||||
|
text = extras.text(Notification.EXTRA_TEXT),
|
||||||
|
bigText = extras.text(Notification.EXTRA_BIG_TEXT),
|
||||||
|
subtext = extras.text(Notification.EXTRA_SUB_TEXT),
|
||||||
|
messages = messages,
|
||||||
|
channelId = notification.channelId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bundle.text(key: String) = getCharSequence(key)?.toString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.module
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicy
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyJson
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
|
||||||
|
|
||||||
|
internal object VisibilityPolicySync {
|
||||||
|
const val UPDATE_ACTION = "se.ajpanton.notificationsmaster.UPDATE_VISIBILITY_POLICY"
|
||||||
|
const val REQUEST_ACTION = "se.ajpanton.notificationsmaster.REQUEST_VISIBILITY_POLICY"
|
||||||
|
const val EXTRA_POLICY = "policy"
|
||||||
|
const val EXTRA_REQUESTER = "requester"
|
||||||
|
const val PERMISSION = AlertPolicySync.PERMISSION
|
||||||
|
const val APP_PACKAGE = "se.ajpanton.notificationsmaster"
|
||||||
|
const val REQUEST_RECEIVER = "$APP_PACKAGE.module.VisibilityPolicyRequestReceiver"
|
||||||
|
const val MAX_POLICY_BYTES = 128 * 1024
|
||||||
|
val TARGET_PACKAGES = setOf("com.android.systemui", "com.samsung.android.app.aodservice")
|
||||||
|
|
||||||
|
fun publish(context: Context, policy: VisibilityPolicy) {
|
||||||
|
val payload = VisibilityPolicyJson.encode(policy)
|
||||||
|
if (payload.size > MAX_POLICY_BYTES) {
|
||||||
|
Log.w(TAG, "Visibility policy exceeds the process-cache limit; allowing all notifications")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TARGET_PACKAGES.forEach { target ->
|
||||||
|
context.sendBroadcast(Intent(UPDATE_ACTION).setPackage(target).putExtra(EXTRA_POLICY, payload))
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Published visibility policy generation ${policy.generation}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publishStored(context: Context) {
|
||||||
|
val policy = runCatching { VisibilityPolicyStore(context).load() }
|
||||||
|
.onFailure { Log.e(TAG, "Stored visibility policy is unreadable; allowing all notifications", it) }
|
||||||
|
.getOrElse { VisibilityPolicy(enabled = false) }
|
||||||
|
publish(context, policy)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TAG = "NotificationsMaster"
|
||||||
|
}
|
||||||
|
|
||||||
|
class VisibilityPolicyRequestReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != VisibilityPolicySync.REQUEST_ACTION) return
|
||||||
|
val requester = intent.getParcelableExtra(VisibilityPolicySync.EXTRA_REQUESTER, PendingIntent::class.java)
|
||||||
|
val creatorPackages = requester?.let {
|
||||||
|
context.packageManager.getPackagesForUid(it.creatorUid).orEmpty().toSet()
|
||||||
|
}.orEmpty()
|
||||||
|
val creatorPackage = requester?.creatorPackage
|
||||||
|
if (creatorPackage !in VisibilityPolicySync.TARGET_PACKAGES ||
|
||||||
|
creatorPackage !in creatorPackages
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "Rejected visibility-policy request without a trusted identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
VisibilityPolicySync.publishStored(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object { const val TAG = "NotificationsMaster" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.module
|
||||||
|
|
||||||
|
internal interface VisibilitySurfaceBackend {
|
||||||
|
fun install(classLoader: ClassLoader): Boolean
|
||||||
|
fun onPolicyChanged()
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
data class ListedApp(
|
data class ListedApp(
|
||||||
val label: String,
|
val label: String,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
enum class LogField { TIMESTAMP, APP_NAME, PACKAGE_NAME, ACTION, CONTENTS }
|
enum class LogField { TIMESTAMP, APP_NAME, PACKAGE_NAME, ACTION, CONTENTS }
|
||||||
enum class TimestampZone { EVENT_LOCAL, LOCAL_NOW, UTC }
|
enum class TimestampZone { EVENT_LOCAL, LOCAL_NOW, UTC }
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
+2
-6
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
@@ -28,11 +28,7 @@ class LoggingRuleStore(context: Context) {
|
|||||||
private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix"
|
private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix"
|
||||||
|
|
||||||
private fun updateListenerComponent() {
|
private fun updateListenerComponent() {
|
||||||
NotificationListenerComponentController.update(
|
NotificationListenerComponentController.synchronize(appContext)
|
||||||
appContext,
|
|
||||||
LoggingType.entries.associateWith(::ruleFor),
|
|
||||||
PerAppEventSettingsStore(appContext).hasEnabledEventOverride(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
enum class LoggingType {
|
enum class LoggingType {
|
||||||
APPEARING,
|
APPEARING,
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.service.notification.NotificationListenerService
|
||||||
|
import android.util.Log
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
||||||
|
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
|
||||||
|
import se.ajpanton.notificationsmaster.module.AlertPolicyBootReceiver
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification listeners are re-bound by Android after boot when their component
|
||||||
|
* is enabled and the user granted notification access. Disabling the component
|
||||||
|
* is therefore both the no-work battery mode and the no-start-on-boot mode.
|
||||||
|
*/
|
||||||
|
internal object NotificationListenerComponentController {
|
||||||
|
fun synchronize(context: Context) {
|
||||||
|
val applicationContext = context.applicationContext
|
||||||
|
val rules = LoggingRuleStore(applicationContext)
|
||||||
|
update(
|
||||||
|
applicationContext,
|
||||||
|
LoggingType.entries.associateWith(rules::ruleFor),
|
||||||
|
PerAppEventSettingsStore(applicationContext).hasEnabledEventOverride(),
|
||||||
|
AlertConfigurationStore(applicationContext).load().needsListener,
|
||||||
|
VisibilityPolicyStore(applicationContext).load().hasRules,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(
|
||||||
|
context: Context,
|
||||||
|
rules: Map<LoggingType, LoggingRule>,
|
||||||
|
hasEnabledEventOverride: Boolean = false,
|
||||||
|
hasEnabledAlertRule: Boolean = false,
|
||||||
|
hasVisibilityRules: Boolean = false,
|
||||||
|
) {
|
||||||
|
val applicationContext = context.applicationContext
|
||||||
|
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
|
||||||
|
val packageManager = applicationContext.packageManager
|
||||||
|
val desired = if (NotificationListenerPolicy.shouldRun(
|
||||||
|
rules,
|
||||||
|
hasEnabledEventOverride,
|
||||||
|
hasEnabledAlertRule,
|
||||||
|
hasVisibilityRules,
|
||||||
|
)) {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
} else {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||||
|
}
|
||||||
|
if (packageManager.getComponentEnabledSetting(component) != desired) {
|
||||||
|
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
|
||||||
|
}
|
||||||
|
val bootReceiver = ComponentName(applicationContext, AlertPolicyBootReceiver::class.java)
|
||||||
|
val bootReceiverState = if (hasEnabledAlertRule || hasVisibilityRules) {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
} else {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||||
|
}
|
||||||
|
if (packageManager.getComponentEnabledSetting(bootReceiver) != bootReceiverState) {
|
||||||
|
packageManager.setComponentEnabledSetting(bootReceiver, bootReceiverState, PackageManager.DONT_KILL_APP)
|
||||||
|
}
|
||||||
|
if (desired == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
||||||
|
try {
|
||||||
|
// Data clearing and OEM listener management can leave an enabled
|
||||||
|
// component without a live binding. Ask Android to reconnect on
|
||||||
|
// every enabled-rule synchronization, not just a state change.
|
||||||
|
NotificationListenerService.requestRebind(component)
|
||||||
|
} catch (error: SecurityException) {
|
||||||
|
Log.w(TAG, "Notification access is not granted yet; Android will bind after the user grants it", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TAG = "NotificationListenerControl"
|
||||||
|
}
|
||||||
+7
-2
@@ -1,9 +1,14 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
/** The listener is useful only when at least one event type is enabled. */
|
/** The listener is useful only when at least one event type is enabled. */
|
||||||
object NotificationListenerPolicy {
|
object NotificationListenerPolicy {
|
||||||
fun shouldRun(
|
fun shouldRun(
|
||||||
rules: Map<LoggingType, LoggingRule>,
|
rules: Map<LoggingType, LoggingRule>,
|
||||||
hasEnabledEventOverride: Boolean = false,
|
hasEnabledEventOverride: Boolean = false,
|
||||||
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } || hasEnabledEventOverride
|
hasEnabledAlertRule: Boolean = false,
|
||||||
|
hasVisibilityRules: Boolean = false,
|
||||||
|
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } ||
|
||||||
|
hasEnabledEventOverride ||
|
||||||
|
hasEnabledAlertRule ||
|
||||||
|
hasVisibilityRules
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
object NotificationRuleEvaluator {
|
object NotificationRuleEvaluator {
|
||||||
fun allows(filter: AppFilterSettings, packageName: String): Boolean {
|
fun allows(filter: AppFilterSettings, packageName: String): Boolean {
|
||||||
+2
-3
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
@@ -52,8 +52,7 @@ class PerAppEventSettingsStore(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateListenerComponent() {
|
private fun updateListenerComponent() {
|
||||||
val rules = LoggingType.entries.associateWith { LoggingRuleStore(appContext).ruleFor(it) }
|
NotificationListenerComponentController.synchronize(appContext)
|
||||||
NotificationListenerComponentController.update(appContext, rules, hasEnabledEventOverride())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun key(packageName: String) = "$KEY_PREFIX$packageName"
|
private fun key(packageName: String) = "$KEY_PREFIX$packageName"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package se.ajpanton.notificationlog.settings
|
package se.ajpanton.notificationsmaster.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.text.format.DateFormat as AndroidDateFormat
|
import android.text.format.DateFormat as AndroidDateFormat
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.visibility
|
||||||
|
|
||||||
|
data class CardsGridRows(
|
||||||
|
val rowSizes: List<Int>,
|
||||||
|
val showsOverflow: Boolean,
|
||||||
|
) {
|
||||||
|
val visibleSlots get() = rowSizes.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
object CardsGridLayout {
|
||||||
|
fun arrange(itemCount: Int, settings: CardsGridSettings, widthCapacity: Int? = null): CardsGridRows {
|
||||||
|
require(itemCount >= 0)
|
||||||
|
val perRow = if (settings.limitToScreenWidth && widthCapacity != null) {
|
||||||
|
minOf(settings.maxIconsPerRow, widthCapacity.coerceAtLeast(0))
|
||||||
|
} else {
|
||||||
|
settings.maxIconsPerRow
|
||||||
|
}
|
||||||
|
val capacity = perRow * settings.maxRows
|
||||||
|
if (itemCount == 0 || capacity == 0) return CardsGridRows(emptyList(), itemCount > 0)
|
||||||
|
|
||||||
|
val overflow = itemCount > capacity
|
||||||
|
val slots = minOf(itemCount, capacity)
|
||||||
|
val rows = minOf(settings.maxRows, (slots + perRow - 1) / perRow)
|
||||||
|
if (!settings.evenDistribution || rows == 1) {
|
||||||
|
var remaining = slots
|
||||||
|
return CardsGridRows(List(rows) {
|
||||||
|
minOf(perRow, remaining).also { remaining -= it }
|
||||||
|
}, overflow)
|
||||||
|
}
|
||||||
|
val base = slots / rows
|
||||||
|
val remainder = slots % rows
|
||||||
|
return CardsGridRows(List(rows) { base + if (it < remainder) 1 else 0 }, overflow)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.visibility
|
||||||
|
|
||||||
|
import com.google.re2j.Pattern
|
||||||
|
|
||||||
|
enum class NotificationSurface { AOD, LOCKSCREEN_COLLAPSED, UNLOCKED_STATUSBAR, DRAWER }
|
||||||
|
|
||||||
|
data class CardsGridSettings(
|
||||||
|
val maxRows: Int = 1,
|
||||||
|
val maxIconsPerRow: Int = 5,
|
||||||
|
val evenDistribution: Boolean = true,
|
||||||
|
val limitToScreenWidth: Boolean = true,
|
||||||
|
val iconHeightPercent: Int,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(maxRows in 0..5)
|
||||||
|
require(maxIconsPerRow in 1..15)
|
||||||
|
require(iconHeightPercent in 10..100)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val LOCKSCREEN_DEFAULT = CardsGridSettings(iconHeightPercent = 59)
|
||||||
|
val AOD_DEFAULT = CardsGridSettings(iconHeightPercent = 65)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class VisibilityExceptionRule(
|
||||||
|
val pattern: String,
|
||||||
|
val blockedSurfaces: Set<NotificationSurface>,
|
||||||
|
) {
|
||||||
|
init { require(pattern.isNotBlank()) }
|
||||||
|
|
||||||
|
fun hasValidPattern() = runCatching { Pattern.compile(pattern) }.isSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AppVisibilityPolicy(
|
||||||
|
val packageName: String,
|
||||||
|
val blockedSurfaces: Set<NotificationSurface> = emptySet(),
|
||||||
|
val exceptions: List<VisibilityExceptionRule> = emptyList(),
|
||||||
|
) {
|
||||||
|
init { require(packageName.isNotBlank()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SystemUiMiscSettings(
|
||||||
|
val keepAodVisibleDuringCalls: Boolean = false,
|
||||||
|
val showLockscreenBatteryPercentWhenUnplugged: Boolean = true,
|
||||||
|
val showAodBatteryPercentWhenUnplugged: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class VisibilityPolicy(
|
||||||
|
val generation: Long = 0,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val apps: List<AppVisibilityPolicy> = emptyList(),
|
||||||
|
val unlockedIconLimit: Int? = null,
|
||||||
|
val lockscreenCards: CardsGridSettings = CardsGridSettings.LOCKSCREEN_DEFAULT,
|
||||||
|
val aodCards: CardsGridSettings = CardsGridSettings.AOD_DEFAULT,
|
||||||
|
val systemUiMisc: SystemUiMiscSettings = SystemUiMiscSettings(),
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(generation >= 0)
|
||||||
|
require(apps.map { it.packageName }.distinct().size == apps.size)
|
||||||
|
require(unlockedIconLimit == null || unlockedIconLimit in 1..1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasRules get() = enabled && (unlockedIconLimit != null ||
|
||||||
|
apps.any { it.blockedSurfaces.isNotEmpty() || it.exceptions.isNotEmpty() })
|
||||||
|
}
|
||||||
|
|
||||||
|
data class VisibilityNotification(
|
||||||
|
val packageName: String,
|
||||||
|
val key: String,
|
||||||
|
val title: String? = null,
|
||||||
|
val text: String? = null,
|
||||||
|
val bigText: String? = null,
|
||||||
|
val subtext: String? = null,
|
||||||
|
val messages: List<String> = emptyList(),
|
||||||
|
val channelId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class CompiledVisibilityPolicy(policy: VisibilityPolicy) {
|
||||||
|
val generation = policy.generation
|
||||||
|
val unlockedIconLimit = policy.unlockedIconLimit
|
||||||
|
val lockscreenCards = policy.lockscreenCards
|
||||||
|
val aodCards = policy.aodCards
|
||||||
|
val systemUiMisc = policy.systemUiMisc
|
||||||
|
private val enabled = policy.enabled
|
||||||
|
private val apps = policy.apps.associate { app ->
|
||||||
|
app.packageName to CompiledAppPolicy(
|
||||||
|
app.blockedSurfaces,
|
||||||
|
app.exceptions.map { CompiledRule(Pattern.compile(it.pattern), it.blockedSurfaces) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isBlocked(notification: VisibilityNotification, surface: NotificationSurface): Boolean {
|
||||||
|
if (!enabled) return false
|
||||||
|
val policy = apps[notification.packageName] ?: return false
|
||||||
|
val matchTexts = notification.matchTexts()
|
||||||
|
val exception = policy.exceptions.firstOrNull { rule ->
|
||||||
|
matchTexts.any { rule.pattern.matcher(it).find() }
|
||||||
|
}
|
||||||
|
return surface in (exception?.blockedSurfaces ?: policy.blockedSurfaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasExceptions(packageName: String) = apps[packageName]?.exceptions?.isNotEmpty() == true
|
||||||
|
|
||||||
|
private fun VisibilityNotification.matchTexts() = buildList {
|
||||||
|
add(packageName)
|
||||||
|
add(key)
|
||||||
|
listOf(title, text, bigText, subtext, channelId).filterNotNullTo(this)
|
||||||
|
addAll(messages)
|
||||||
|
}.map(String::trim).filter(String::isNotEmpty).let { fields ->
|
||||||
|
fields + fields.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CompiledAppPolicy(
|
||||||
|
val blockedSurfaces: Set<NotificationSurface>,
|
||||||
|
val exceptions: List<CompiledRule>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class CompiledRule(
|
||||||
|
val pattern: Pattern,
|
||||||
|
val blockedSurfaces: Set<NotificationSurface>,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val ALLOW_ALL = CompiledVisibilityPolicy(VisibilityPolicy(enabled = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.visibility
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
internal object VisibilityPolicyJson {
|
||||||
|
const val SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
fun encode(policy: VisibilityPolicy): ByteArray = JSONObject()
|
||||||
|
.put("version", SCHEMA_VERSION)
|
||||||
|
.put("generation", policy.generation)
|
||||||
|
.put("enabled", policy.enabled)
|
||||||
|
.put("unlockedIconLimit", policy.unlockedIconLimit ?: JSONObject.NULL)
|
||||||
|
.put("lockscreenCards", policy.lockscreenCards.toJson())
|
||||||
|
.put("aodCards", policy.aodCards.toJson())
|
||||||
|
.put("systemUiMisc", policy.systemUiMisc.toJson())
|
||||||
|
.put("apps", JSONArray().apply { policy.apps.forEach { put(it.toJson()) } })
|
||||||
|
.toString().encodeToByteArray()
|
||||||
|
|
||||||
|
fun decode(bytes: ByteArray): VisibilityPolicy {
|
||||||
|
val root = JSONObject(bytes.decodeToString())
|
||||||
|
require(root.getInt("version") == SCHEMA_VERSION)
|
||||||
|
return VisibilityPolicy(
|
||||||
|
generation = root.getLong("generation"),
|
||||||
|
enabled = root.getBoolean("enabled"),
|
||||||
|
apps = root.getJSONArray("apps").map { (it as JSONObject).toAppPolicy() },
|
||||||
|
unlockedIconLimit = if (root.isNull("unlockedIconLimit")) null else root.getInt("unlockedIconLimit"),
|
||||||
|
lockscreenCards = root.optJSONObject("lockscreenCards")?.toCardsGridSettings()
|
||||||
|
?: CardsGridSettings.LOCKSCREEN_DEFAULT,
|
||||||
|
aodCards = root.optJSONObject("aodCards")?.toCardsGridSettings()
|
||||||
|
?: CardsGridSettings.AOD_DEFAULT,
|
||||||
|
systemUiMisc = root.optJSONObject("systemUiMisc")?.toSystemUiMiscSettings()
|
||||||
|
?: SystemUiMiscSettings(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SystemUiMiscSettings.toJson() = JSONObject()
|
||||||
|
.put("keepAodVisibleDuringCalls", keepAodVisibleDuringCalls)
|
||||||
|
.put("showLockscreenBatteryPercentWhenUnplugged", showLockscreenBatteryPercentWhenUnplugged)
|
||||||
|
.put("showAodBatteryPercentWhenUnplugged", showAodBatteryPercentWhenUnplugged)
|
||||||
|
|
||||||
|
private fun JSONObject.toSystemUiMiscSettings() = SystemUiMiscSettings(
|
||||||
|
keepAodVisibleDuringCalls = getBoolean("keepAodVisibleDuringCalls"),
|
||||||
|
showLockscreenBatteryPercentWhenUnplugged = getBoolean("showLockscreenBatteryPercentWhenUnplugged"),
|
||||||
|
showAodBatteryPercentWhenUnplugged = getBoolean("showAodBatteryPercentWhenUnplugged"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CardsGridSettings.toJson() = JSONObject()
|
||||||
|
.put("maxRows", maxRows)
|
||||||
|
.put("maxIconsPerRow", maxIconsPerRow)
|
||||||
|
.put("evenDistribution", evenDistribution)
|
||||||
|
.put("limitToScreenWidth", limitToScreenWidth)
|
||||||
|
.put("iconHeightPercent", iconHeightPercent)
|
||||||
|
|
||||||
|
private fun JSONObject.toCardsGridSettings() = CardsGridSettings(
|
||||||
|
maxRows = getInt("maxRows"),
|
||||||
|
maxIconsPerRow = getInt("maxIconsPerRow"),
|
||||||
|
evenDistribution = getBoolean("evenDistribution"),
|
||||||
|
limitToScreenWidth = getBoolean("limitToScreenWidth"),
|
||||||
|
iconHeightPercent = getInt("iconHeightPercent"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun AppVisibilityPolicy.toJson() = JSONObject()
|
||||||
|
.put("packageName", packageName)
|
||||||
|
.put("blockedSurfaces", JSONArray(blockedSurfaces.map { it.name }))
|
||||||
|
.put("exceptions", JSONArray().apply { exceptions.forEach { put(it.toJson()) } })
|
||||||
|
|
||||||
|
private fun VisibilityExceptionRule.toJson() = JSONObject()
|
||||||
|
.put("pattern", pattern)
|
||||||
|
.put("blockedSurfaces", JSONArray(blockedSurfaces.map { it.name }))
|
||||||
|
|
||||||
|
private fun JSONObject.toAppPolicy() = AppVisibilityPolicy(
|
||||||
|
packageName = getString("packageName"),
|
||||||
|
blockedSurfaces = getJSONArray("blockedSurfaces").surfaces(),
|
||||||
|
exceptions = getJSONArray("exceptions").map { (it as JSONObject).toException() },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JSONObject.toException() = VisibilityExceptionRule(
|
||||||
|
pattern = getString("pattern"),
|
||||||
|
blockedSurfaces = getJSONArray("blockedSurfaces").surfaces(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JSONArray.surfaces() = map { NotificationSurface.valueOf(it as String) }.toSet()
|
||||||
|
private fun <T> JSONArray.map(transform: (Any) -> T) = List(length()) { transform(get(it)) }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user