Limit custom alerts to notification events
This commit is contained in:
@@ -46,17 +46,12 @@ play to completion.
|
|||||||
Without optional system integration, a matching profile plays alongside the
|
Without optional system integration, a matching profile plays alongside the
|
||||||
source app's normal alert. With a compatible LSPosed setup enabled, Notifications
|
source app's normal alert. With a compatible LSPosed setup enabled, Notifications
|
||||||
Master can suppress a matched standard notification alert while leaving the
|
Master can suppress a matched standard notification alert while leaving the
|
||||||
notification itself visible. Experimental per-app control is also available for
|
notification itself visible. It only evaluates actual posted or updated
|
||||||
direct vibrations that are explicitly marked by Android as notification usage.
|
notifications; standalone sounds and vibrations remain under the source app's
|
||||||
It deliberately does not touch media, alarms, ringtones, touch feedback, or
|
control.
|
||||||
unknown vibration usage. Direct app-originated sounds are not intercepted: they
|
|
||||||
do not have a safe, complete Android system boundary, so their original sound
|
|
||||||
continues to play.
|
|
||||||
|
|
||||||
To use the optional integration, enable Notifications Master as an LSPosed
|
To use the optional integration, enable Notifications Master as an LSPosed
|
||||||
module and give it the **Android/system server** scope, then restart Android.
|
module and give it the **Android/system server** scope, then restart Android.
|
||||||
Enable direct-vibration control only for apps where you want that experimental
|
|
||||||
behaviour; normal notification rules work independently of this setting.
|
|
||||||
|
|
||||||
Custom alerts respect Do Not Disturb. Android does not allow a normal app to
|
Custom alerts respect Do Not Disturb. Android does not allow a normal app to
|
||||||
force direct custom sound or vibration through Do Not Disturb.
|
force direct custom sound or vibration through Do Not Disturb.
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ class AlertConfigurationStoreTest {
|
|||||||
val profile = AlertProfile("profile", "Sensitive profile", vibrationPattern = listOf(0, 120))
|
val profile = AlertProfile("profile", "Sensitive profile", vibrationPattern = listOf(0, 120))
|
||||||
val configuration = AlertConfiguration(
|
val configuration = AlertConfiguration(
|
||||||
profiles = listOf(profile),
|
profiles = listOf(profile),
|
||||||
appSettings = listOf(AlertAppSettings("chat.app", directAlertControl = true)),
|
appSettings = listOf(AlertAppSettings("chat.app")),
|
||||||
rules = listOf(
|
rules = listOf(
|
||||||
AlertRule(
|
AlertRule(
|
||||||
"rule", "chat.app", 0, setOf(AlertSource.NOTIFICATION_POST),
|
"rule", "chat.app", 0, setOf(AlertSource.NOTIFICATION_POST),
|
||||||
|
|||||||
-19
@@ -48,23 +48,4 @@ class AlertListenerPolicyDeviceTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun directAlertControlKeepsListenerEnabledWithoutRules() {
|
|
||||||
LoggingType.entries.forEach { LoggingRuleStore(context).save(it, LoggingRule(enabled = false)) }
|
|
||||||
|
|
||||||
AlertConfigurationStore(context).save(
|
|
||||||
AlertConfiguration(appSettings = listOf(AlertAppSettings("example.app", directAlertControl = true))),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
|
|
||||||
context.packageManager.getComponentEnabledSetting(component),
|
|
||||||
)
|
|
||||||
|
|
||||||
AlertConfigurationStore(context).save(AlertConfiguration())
|
|
||||||
assertEquals(
|
|
||||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
|
||||||
context.packageManager.getComponentEnabledSetting(component),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-10
@@ -22,7 +22,6 @@ class AlertPolicySyncDeviceTest {
|
|||||||
val profile = AlertProfile("module-test", "Module test", vibrationPattern = listOf(0, 200))
|
val profile = AlertProfile("module-test", "Module test", vibrationPattern = listOf(0, 200))
|
||||||
val configuration = AlertConfiguration(
|
val configuration = AlertConfiguration(
|
||||||
profiles = listOf(profile),
|
profiles = listOf(profile),
|
||||||
appSettings = listOf(AlertAppSettings(HELPER_PACKAGE, directAlertControl = true)),
|
|
||||||
rules = listOf(
|
rules = listOf(
|
||||||
AlertRule(
|
AlertRule(
|
||||||
id = "module-helper-post",
|
id = "module-helper-post",
|
||||||
@@ -33,15 +32,6 @@ class AlertPolicySyncDeviceTest {
|
|||||||
outcome = AlertOutcome.PLAY_PROFILE,
|
outcome = AlertOutcome.PLAY_PROFILE,
|
||||||
profileId = profile.id,
|
profileId = profile.id,
|
||||||
),
|
),
|
||||||
AlertRule(
|
|
||||||
id = "module-helper-direct-vibration",
|
|
||||||
packageName = HELPER_PACKAGE,
|
|
||||||
order = 1,
|
|
||||||
sources = setOf(AlertSource.DIRECT_NOTIFICATION_VIBRATION),
|
|
||||||
matcher = AlertTextMatcher(),
|
|
||||||
outcome = AlertOutcome.PLAY_PROFILE,
|
|
||||||
profileId = profile.id,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val store = AlertConfigurationStore(InstrumentationRegistry.getInstrumentation().targetContext)
|
val store = AlertConfigurationStore(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||||
|
|||||||
@@ -53,14 +53,6 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".module.DirectAlertReceiver"
|
|
||||||
android:exported="true"
|
|
||||||
android:permission="se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="se.ajpanton.notificationsmaster.PLAY_DIRECT_ALERT" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -62,10 +62,6 @@ object AlertRuleDialog {
|
|||||||
}
|
}
|
||||||
val post = CheckBox(context).apply { text = "Appearing"; isChecked = existing == null || AlertSource.NOTIFICATION_POST in existing.sources }
|
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 update = CheckBox(context).apply { text = "Edits"; isChecked = existing == null || AlertSource.NOTIFICATION_UPDATE in existing.sources }
|
||||||
val directVibration = CheckBox(context).apply {
|
|
||||||
text = "Direct notification vibration (experimental)"
|
|
||||||
isChecked = AlertSource.DIRECT_NOTIFICATION_VIBRATION in (existing?.sources ?: emptySet())
|
|
||||||
}
|
|
||||||
val pattern = EditText(context).apply { hint = "Text to match (optional)"; setText(existing?.matcher?.value) }
|
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 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 protected = SwitchMaterial(context).apply { text = "Play to completion"; isChecked = existing?.playToCompletion == true }
|
||||||
@@ -73,7 +69,7 @@ object AlertRuleDialog {
|
|||||||
text = "Allow during DND (requires system support)"
|
text = "Allow during DND (requires system support)"
|
||||||
isChecked = existing?.allowDuringDnd == true
|
isChecked = existing?.allowDuringDnd == true
|
||||||
}
|
}
|
||||||
listOf(outcome, profile, post, update, directVibration, pattern, regex, protected, dnd).forEach(form::addView)
|
listOf(outcome, profile, post, update, pattern, regex, protected, dnd).forEach(form::addView)
|
||||||
form.addView(android.widget.TextView(context).apply {
|
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."
|
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
|
textSize = 14f
|
||||||
@@ -96,7 +92,6 @@ object AlertRuleDialog {
|
|||||||
val sources = buildSet {
|
val sources = buildSet {
|
||||||
if (post.isChecked) add(AlertSource.NOTIFICATION_POST)
|
if (post.isChecked) add(AlertSource.NOTIFICATION_POST)
|
||||||
if (update.isChecked) add(AlertSource.NOTIFICATION_UPDATE)
|
if (update.isChecked) add(AlertSource.NOTIFICATION_UPDATE)
|
||||||
if (directVibration.isChecked) add(AlertSource.DIRECT_NOTIFICATION_VIBRATION)
|
|
||||||
}
|
}
|
||||||
if (sources.isEmpty()) { post.error = "Choose an event"; return@setOnClickListener }
|
if (sources.isEmpty()) { post.error = "Choose an event"; return@setOnClickListener }
|
||||||
if (apps != null && selectedApps.isEmpty()) { app?.error = "Choose at least one app"; return@setOnClickListener }
|
if (apps != null && selectedApps.isEmpty()) { app?.error = "Choose at least one app"; return@setOnClickListener }
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ class AppRulesFragment : Fragment(R.layout.fragment_app_rules) {
|
|||||||
binding!!.addRule.setOnClickListener { edit(null) }
|
binding!!.addRule.setOnClickListener { edit(null) }
|
||||||
binding!!.allowMultipleMatches.isChecked = store.load().appSettings.firstOrNull { it.packageName == packageName }?.allowMultipleMatches == true
|
binding!!.allowMultipleMatches.isChecked = store.load().appSettings.firstOrNull { it.packageName == packageName }?.allowMultipleMatches == true
|
||||||
binding!!.allowMultipleMatches.setOnCheckedChangeListener { _, enabled -> setAllowMultipleMatches(enabled) }
|
binding!!.allowMultipleMatches.setOnCheckedChangeListener { _, enabled -> setAllowMultipleMatches(enabled) }
|
||||||
binding!!.directAlertControl.isChecked = store.load().appSettings.firstOrNull { it.packageName == packageName }?.directAlertControl == true
|
|
||||||
binding!!.directAlertControl.setOnCheckedChangeListener { _, enabled -> setDirectAlertControl(enabled) }
|
|
||||||
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
|
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
|
||||||
override fun onMove(recyclerView: RecyclerView, holder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
|
override fun onMove(recyclerView: RecyclerView, holder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
|
||||||
adapter.move(holder.bindingAdapterPosition, target.bindingAdapterPosition)
|
adapter.move(holder.bindingAdapterPosition, target.bindingAdapterPosition)
|
||||||
@@ -75,15 +73,9 @@ class AppRulesFragment : Fragment(R.layout.fragment_app_rules) {
|
|||||||
saveAppSettings(configuration, setting)
|
saveAppSettings(configuration, setting)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setDirectAlertControl(enabled: Boolean) {
|
|
||||||
val configuration = store.load()
|
|
||||||
val old = configuration.appSettings.firstOrNull { it.packageName == packageName }
|
|
||||||
saveAppSettings(configuration, (old ?: AlertAppSettings(packageName)).copy(directAlertControl = enabled))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveAppSettings(configuration: se.ajpanton.notificationsmaster.alerts.AlertConfiguration, setting: AlertAppSettings) {
|
private fun saveAppSettings(configuration: se.ajpanton.notificationsmaster.alerts.AlertConfiguration, setting: AlertAppSettings) {
|
||||||
store.save(configuration.copy(appSettings = configuration.appSettings.filterNot { it.packageName == packageName } +
|
store.save(configuration.copy(appSettings = configuration.appSettings.filterNot { it.packageName == packageName } +
|
||||||
if (setting.allowMultipleMatches || setting.directAlertControl) listOf(setting) else emptyList()))
|
if (setting.allowMultipleMatches) listOf(setting) else emptyList()))
|
||||||
}
|
}
|
||||||
|
|
||||||
private class RuleAdapter(private val onClick: (AlertRule) -> Unit) : RecyclerView.Adapter<RuleAdapter.Holder>() {
|
private class RuleAdapter(private val onClick: (AlertRule) -> Unit) : RecyclerView.Adapter<RuleAdapter.Holder>() {
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ 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.notificationsmaster.alerts.AlertPlaybackRuntime
|
|
||||||
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
||||||
import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
|
import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
|
||||||
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
||||||
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
|
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
|
||||||
|
|
||||||
class NotificationLogApplication : Application() {
|
class NotificationLogApplication : Application() {
|
||||||
val alertPlayback by lazy { AlertPlaybackRuntime(this) }
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
synchronizeListenerComponent()
|
synchronizeListenerComponent()
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ internal object AlertConfigurationJson {
|
|||||||
|
|
||||||
private fun AlertAppSettings.toJson() = JSONObject()
|
private fun AlertAppSettings.toJson() = JSONObject()
|
||||||
.put("packageName", packageName).put("allowMultipleMatches", allowMultipleMatches)
|
.put("packageName", packageName).put("allowMultipleMatches", allowMultipleMatches)
|
||||||
.put("directAlertControl", directAlertControl)
|
|
||||||
|
|
||||||
private fun AlertRule.toJson() = JSONObject()
|
private fun AlertRule.toJson() = JSONObject()
|
||||||
.put("id", id).put("packageName", packageName).put("order", order)
|
.put("id", id).put("packageName", packageName).put("order", order)
|
||||||
@@ -113,7 +112,7 @@ internal object AlertConfigurationJson {
|
|||||||
)
|
)
|
||||||
|
|
||||||
private fun JSONObject.toAppSettings() = AlertAppSettings(
|
private fun JSONObject.toAppSettings() = AlertAppSettings(
|
||||||
getString("packageName"), getBoolean("allowMultipleMatches"), getBoolean("directAlertControl"),
|
getString("packageName"), getBoolean("allowMultipleMatches"),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun JSONObject.toRule() = AlertRule(
|
private fun JSONObject.toRule() = AlertRule(
|
||||||
|
|||||||
@@ -5,17 +5,11 @@ import com.google.re2j.Pattern
|
|||||||
enum class AlertSource {
|
enum class AlertSource {
|
||||||
NOTIFICATION_POST,
|
NOTIFICATION_POST,
|
||||||
NOTIFICATION_UPDATE,
|
NOTIFICATION_UPDATE,
|
||||||
DIRECT_NOTIFICATION_SOUND,
|
|
||||||
DIRECT_NOTIFICATION_VIBRATION,
|
|
||||||
;
|
;
|
||||||
|
|
||||||
val isDirect get() = this == DIRECT_NOTIFICATION_SOUND || this == DIRECT_NOTIFICATION_VIBRATION
|
|
||||||
|
|
||||||
fun label() = when (this) {
|
fun label() = when (this) {
|
||||||
NOTIFICATION_POST -> "Appearing"
|
NOTIFICATION_POST -> "Appearing"
|
||||||
NOTIFICATION_UPDATE -> "Edits"
|
NOTIFICATION_UPDATE -> "Edits"
|
||||||
DIRECT_NOTIFICATION_SOUND -> "Direct notification sound"
|
|
||||||
DIRECT_NOTIFICATION_VIBRATION -> "Direct notification vibration"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +107,6 @@ data class AlertRuleGroupingKey(
|
|||||||
data class AlertAppSettings(
|
data class AlertAppSettings(
|
||||||
val packageName: String,
|
val packageName: String,
|
||||||
val allowMultipleMatches: Boolean = false,
|
val allowMultipleMatches: Boolean = false,
|
||||||
val directAlertControl: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AlertQueueSettings(
|
data class AlertQueueSettings(
|
||||||
@@ -143,8 +136,7 @@ data class AlertConfiguration(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val hasEnabledRules get() = enabled && rules.any { it.enabled }
|
val hasEnabledRules get() = enabled && rules.any { it.enabled }
|
||||||
val hasDirectAlertControl get() = enabled && appSettings.any { it.directAlertControl }
|
val needsListener get() = hasEnabledRules
|
||||||
val needsListener get() = hasEnabledRules || hasDirectAlertControl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class AlertEvent(
|
data class AlertEvent(
|
||||||
@@ -181,8 +173,7 @@ fun AlertDecision.snapshot(): AlertSnapshot? = profile?.let {
|
|||||||
|
|
||||||
data class AlertEvaluation(
|
data class AlertEvaluation(
|
||||||
val decisions: List<AlertDecision>,
|
val decisions: List<AlertDecision>,
|
||||||
val silenceUnmatchedDirectAlert: Boolean,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun AlertEvaluation.suppressesOriginal() =
|
fun AlertEvaluation.suppressesOriginal() =
|
||||||
silenceUnmatchedDirectAlert || decisions.any { it.outcome != AlertOutcome.PASS_THROUGH }
|
decisions.any { it.outcome != AlertOutcome.PASS_THROUGH }
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
package se.ajpanton.notificationsmaster.alerts
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
|
|
||||||
/** Process-wide playback queue shared by the notification listener and direct-alert handoff. */
|
|
||||||
class AlertPlaybackRuntime(context: Context) {
|
|
||||||
private var queueSettings = AlertQueueSettings()
|
|
||||||
private val controller = AlertPlaybackController(AndroidAlertEffectPlayer(context)) { queueSettings }
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun enqueue(alert: QueuedAlert, settings: AlertQueueSettings) {
|
|
||||||
queueSettings = settings
|
|
||||||
controller.enqueue(alert)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun stop() = controller.stop()
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ object AlertPolicyEvaluator {
|
|||||||
isRoutineUpdate &&
|
isRoutineUpdate &&
|
||||||
configuration.ignoreRoutineUpdates
|
configuration.ignoreRoutineUpdates
|
||||||
) {
|
) {
|
||||||
return AlertEvaluation(emptyList(), false)
|
return AlertEvaluation(emptyList())
|
||||||
}
|
}
|
||||||
val app = configuration.appSettings.firstOrNull { it.packageName == event.packageName }
|
val app = configuration.appSettings.firstOrNull { it.packageName == event.packageName }
|
||||||
?: AlertAppSettings(event.packageName)
|
?: AlertAppSettings(event.packageName)
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ object AlertRuleEvaluator {
|
|||||||
profiles: Map<String, AlertProfile>,
|
profiles: Map<String, AlertProfile>,
|
||||||
event: AlertEvent,
|
event: AlertEvent,
|
||||||
): AlertEvaluation {
|
): AlertEvaluation {
|
||||||
if (app.packageName != event.packageName || event.source.isDirect && !app.directAlertControl) {
|
if (app.packageName != event.packageName) return AlertEvaluation(emptyList())
|
||||||
return AlertEvaluation(emptyList(), false)
|
|
||||||
}
|
|
||||||
val matches = rules.asSequence()
|
val matches = rules.asSequence()
|
||||||
.filter { it.enabled && it.packageName == event.packageName && event.source in it.sources }
|
.filter { it.enabled && it.packageName == event.packageName && event.source in it.sources }
|
||||||
.sortedWith(compareBy<AlertRule> { it.order }.thenBy { it.id })
|
.sortedWith(compareBy<AlertRule> { it.order }.thenBy { it.id })
|
||||||
@@ -17,7 +15,7 @@ object AlertRuleEvaluator {
|
|||||||
.mapNotNull { rule -> rule.decision(profiles[rule.profileId]) }
|
.mapNotNull { rule -> rule.decision(profiles[rule.profileId]) }
|
||||||
.let { if (app.allowMultipleMatches) it else it.take(1) }
|
.let { if (app.allowMultipleMatches) it else it.take(1) }
|
||||||
.toList()
|
.toList()
|
||||||
return AlertEvaluation(matches, event.source.isDirect && matches.isEmpty())
|
return AlertEvaluation(matches)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AlertRule.decision(profile: AlertProfile?): AlertDecision? = when (outcome) {
|
private fun AlertRule.decision(profile: AlertProfile?): AlertDecision? = when (outcome) {
|
||||||
|
|||||||
+5
-4
@@ -9,9 +9,10 @@ import android.util.Log
|
|||||||
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
||||||
import se.ajpanton.notificationsmaster.alerts.AlertEvent
|
import se.ajpanton.notificationsmaster.alerts.AlertEvent
|
||||||
import se.ajpanton.notificationsmaster.alerts.AlertNotificationDispatcher
|
import se.ajpanton.notificationsmaster.alerts.AlertNotificationDispatcher
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertPlaybackController
|
||||||
import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings
|
import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings
|
||||||
import se.ajpanton.notificationsmaster.alerts.AlertSource
|
import se.ajpanton.notificationsmaster.alerts.AlertSource
|
||||||
import se.ajpanton.notificationsmaster.NotificationLogApplication
|
import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer
|
||||||
import se.ajpanton.notificationsmaster.module.AlertPolicySync
|
import se.ajpanton.notificationsmaster.module.AlertPolicySync
|
||||||
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
||||||
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
|
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
|
||||||
@@ -36,7 +37,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
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 alertConfigurationStore: AlertConfigurationStore
|
||||||
private lateinit var alertPlayback: se.ajpanton.notificationsmaster.alerts.AlertPlaybackRuntime
|
private lateinit var alertPlayback: AlertPlaybackController
|
||||||
private var alertQueueSettings = AlertQueueSettings()
|
private var alertQueueSettings = AlertQueueSettings()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -48,7 +49,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
perAppEventSettings = PerAppEventSettingsStore(this)
|
perAppEventSettings = PerAppEventSettingsStore(this)
|
||||||
imageStore = EncryptedImageStore(this)
|
imageStore = EncryptedImageStore(this)
|
||||||
alertConfigurationStore = AlertConfigurationStore(this)
|
alertConfigurationStore = AlertConfigurationStore(this)
|
||||||
alertPlayback = (application as NotificationLogApplication).alertPlayback
|
alertPlayback = AlertPlaybackController(AndroidAlertEffectPlayer(this)) { alertQueueSettings }
|
||||||
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
||||||
Thread(runnable, "notification-log-writer")
|
Thread(runnable, "notification-log-writer")
|
||||||
}
|
}
|
||||||
@@ -189,7 +190,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
configuration,
|
configuration,
|
||||||
AlertEvent(snapshot.packageName, source, snapshot.alertTitle, snapshot.alertBody, snapshot.alertSender),
|
AlertEvent(snapshot.packageName, source, snapshot.alertTitle, snapshot.alertBody, snapshot.alertSender),
|
||||||
isRoutineUpdate = source == AlertSource.NOTIFICATION_UPDATE && snapshot.isRoutine,
|
isRoutineUpdate = source == AlertSource.NOTIFICATION_UPDATE && snapshot.isRoutine,
|
||||||
).forEach { alertPlayback.enqueue(it, alertQueueSettings) }
|
).forEach(alertPlayback::enqueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun appName(packageName: String): String = try {
|
private fun appName(packageName: String): String = try {
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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.NotificationLogApplication
|
|
||||||
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
|
||||||
import se.ajpanton.notificationsmaster.alerts.QueuedAlert
|
|
||||||
|
|
||||||
/** Signature-protected handoff that can start the app when its listener process was evicted. */
|
|
||||||
class DirectAlertReceiver : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (intent.action != DirectAlertSync.ACTION) return
|
|
||||||
val request = DirectAlertSync.snapshot(intent) ?: run {
|
|
||||||
Log.w(TAG, "Rejected malformed direct alert request")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val (ruleId, snapshot) = request
|
|
||||||
val settings = AlertConfigurationStore(context).load().queueSettings
|
|
||||||
(context.applicationContext as NotificationLogApplication).alertPlayback.enqueue(
|
|
||||||
QueuedAlert(ruleId, snapshot),
|
|
||||||
settings,
|
|
||||||
)
|
|
||||||
Log.i(TAG, "Playing matched direct notification alert")
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "NotificationCapture"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package se.ajpanton.notificationsmaster.module
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.UserHandle
|
|
||||||
import android.os.Binder
|
|
||||||
import se.ajpanton.notificationsmaster.alerts.AlertDecision
|
|
||||||
import se.ajpanton.notificationsmaster.alerts.AlertSnapshot
|
|
||||||
import se.ajpanton.notificationsmaster.alerts.snapshot
|
|
||||||
|
|
||||||
/** One-way requests from system_server to the app's private direct-alert receiver. */
|
|
||||||
internal object DirectAlertSync {
|
|
||||||
const val ACTION = "se.ajpanton.notificationsmaster.PLAY_DIRECT_ALERT"
|
|
||||||
private const val PACKAGE = "se.ajpanton.notificationsmaster"
|
|
||||||
private const val RECEIVER = "$PACKAGE.module.DirectAlertReceiver"
|
|
||||||
private const val EXTRA_RULE_ID = "ruleId"
|
|
||||||
private const val EXTRA_SOUND_URI = "soundUri"
|
|
||||||
private const val EXTRA_VIBRATION = "vibration"
|
|
||||||
private const val EXTRA_PROTECTED = "protected"
|
|
||||||
private const val EXTRA_ALLOW_DND = "allowDnd"
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission") // This method runs only in system_server via the LSPosed module.
|
|
||||||
fun send(context: Context, decision: AlertDecision, userId: Int): Boolean {
|
|
||||||
val snapshot = decision.snapshot() ?: return false
|
|
||||||
val identity = Binder.clearCallingIdentity()
|
|
||||||
try {
|
|
||||||
context.sendBroadcastAsUser(Intent(ACTION).setComponent(ComponentName(PACKAGE, RECEIVER))
|
|
||||||
.putExtra(EXTRA_RULE_ID, decision.ruleId)
|
|
||||||
.putExtra(EXTRA_SOUND_URI, snapshot.soundUri)
|
|
||||||
.putExtra(EXTRA_VIBRATION, snapshot.vibrationPattern.toLongArray())
|
|
||||||
.putExtra(EXTRA_PROTECTED, snapshot.playToCompletion)
|
|
||||||
.putExtra(EXTRA_ALLOW_DND, snapshot.allowDuringDnd), userHandle(userId))
|
|
||||||
} finally {
|
|
||||||
Binder.restoreCallingIdentity(identity)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun snapshot(intent: Intent): Pair<String, AlertSnapshot>? = runCatching {
|
|
||||||
val soundUri = intent.getStringExtra(EXTRA_SOUND_URI)
|
|
||||||
val vibration = intent.getLongArrayExtra(EXTRA_VIBRATION)?.toList().orEmpty()
|
|
||||||
val snapshot = AlertSnapshot(
|
|
||||||
soundUri,
|
|
||||||
vibration,
|
|
||||||
intent.getBooleanExtra(EXTRA_PROTECTED, false),
|
|
||||||
intent.getBooleanExtra(EXTRA_ALLOW_DND, false),
|
|
||||||
)
|
|
||||||
intent.getStringExtra(EXTRA_RULE_ID)?.takeIf(String::isNotBlank)?.let { it to snapshot }
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
private fun userHandle(userId: Int): UserHandle = UserHandle::class.java
|
|
||||||
.getDeclaredConstructor(Int::class.javaPrimitiveType)
|
|
||||||
.newInstance(userId)
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package se.ajpanton.notificationsmaster.module
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.CombinedVibration
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.VibrationAttributes
|
|
||||||
import android.util.Log
|
|
||||||
import io.github.libxposed.api.XposedInterface
|
|
||||||
import java.lang.reflect.Method
|
|
||||||
import se.ajpanton.notificationsmaster.alerts.AlertSource
|
|
||||||
|
|
||||||
/** Intercepts only direct vibrations explicitly labelled as notification effects. */
|
|
||||||
internal class DirectVibrationBridge(private val framework: XposedInterface) {
|
|
||||||
fun install(classLoader: ClassLoader) {
|
|
||||||
val method = findVibrateMethod(classLoader) ?: run {
|
|
||||||
Log.w(TAG, "Direct vibration method shape was not found; leaving Android unchanged")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
method.isAccessible = true
|
|
||||||
framework.hook(method).intercept { chain ->
|
|
||||||
val uid = chain.args.getOrNull(UID_INDEX) as? Int
|
|
||||||
val packageName = chain.args.getOrNull(PACKAGE_INDEX) as? String
|
|
||||||
val attributes = chain.args.getOrNull(ATTRIBUTES_INDEX) as? VibrationAttributes
|
|
||||||
val event = packageName?.takeIf { attributes?.usage == VibrationAttributes.USAGE_NOTIFICATION }
|
|
||||||
?.let { se.ajpanton.notificationsmaster.alerts.AlertEvent(it, AlertSource.DIRECT_NOTIFICATION_VIBRATION) }
|
|
||||||
if (event != null && SystemAlertPolicyCache.shouldSuppress(event)) {
|
|
||||||
SystemAlertPolicyCache.sendDirectAlerts(event, (uid ?: 0).coerceAtLeast(0) / USER_UID_RANGE)
|
|
||||||
Log.i(TAG, "Suppressed matched direct notification vibration from $packageName")
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
chain.proceed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.i(TAG, "Installed API ${Build.VERSION.SDK_INT} direct notification vibration bridge")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findVibrateMethod(classLoader: ClassLoader): Method? = runCatching {
|
|
||||||
Class.forName(VIBRATOR_MANAGER_SERVICE, false, classLoader).declaredMethods.singleOrNull { method ->
|
|
||||||
method.name == "vibrate" && method.returnType == Void.TYPE &&
|
|
||||||
method.parameterTypes.contentEquals(VIBRATE_PARAMETERS)
|
|
||||||
}
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "NotificationsMaster"
|
|
||||||
const val VIBRATOR_MANAGER_SERVICE = "com.android.server.vibrator.VibratorManagerService"
|
|
||||||
const val PACKAGE_INDEX = 2
|
|
||||||
const val UID_INDEX = 0
|
|
||||||
const val ATTRIBUTES_INDEX = 4
|
|
||||||
const val USER_UID_RANGE = 100_000
|
|
||||||
val VIBRATE_PARAMETERS = arrayOf(
|
|
||||||
Int::class.javaPrimitiveType,
|
|
||||||
Int::class.javaPrimitiveType,
|
|
||||||
String::class.java,
|
|
||||||
CombinedVibration::class.java,
|
|
||||||
VibrationAttributes::class.java,
|
|
||||||
String::class.java,
|
|
||||||
IBinder::class.java,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,9 +17,6 @@ class NotificationsMasterModule : XposedModule() {
|
|||||||
installBridge("notification attention") {
|
installBridge("notification attention") {
|
||||||
NotificationAttentionBridge(this, ::diagnoseAttentionHelperEvent).install(param.classLoader)
|
NotificationAttentionBridge(this, ::diagnoseAttentionHelperEvent).install(param.classLoader)
|
||||||
}
|
}
|
||||||
installBridge("direct notification vibration") {
|
|
||||||
DirectVibrationBridge(this).install(param.classLoader)
|
|
||||||
}
|
|
||||||
installBridge("notification enqueue") {
|
installBridge("notification enqueue") {
|
||||||
SystemNotificationBridge(this, ::diagnoseHelperEvent).install(param.classLoader)
|
SystemNotificationBridge(this, ::diagnoseHelperEvent).install(param.classLoader)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,14 +63,7 @@ internal object SystemAlertPolicyCache {
|
|||||||
AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate = false)
|
AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate = false)
|
||||||
|
|
||||||
fun shouldSuppress(event: AlertEvent): Boolean =
|
fun shouldSuppress(event: AlertEvent): Boolean =
|
||||||
(event.source.isDirect || playbackReady) && evaluate(event).suppressesOriginal()
|
playbackReady && evaluate(event).suppressesOriginal()
|
||||||
|
|
||||||
fun sendDirectAlerts(event: AlertEvent, userId: Int) {
|
|
||||||
val context = systemContext() ?: return
|
|
||||||
val decisions = evaluate(event).decisions
|
|
||||||
val sent = decisions.count { DirectAlertSync.send(context, it, userId) }
|
|
||||||
Log.i(TAG, "Sent $sent direct alert request(s) for ${event.packageName}")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun systemContext(): Context? = runCatching {
|
private fun systemContext(): Context? = runCatching {
|
||||||
val activityThread = Class.forName("android.app.ActivityThread")
|
val activityThread = Class.forName("android.app.ActivityThread")
|
||||||
|
|||||||
@@ -25,18 +25,6 @@
|
|||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:text="Allow multiple matching rules" />
|
android:text="Allow multiple matching rules" />
|
||||||
|
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
|
||||||
android:id="@+id/direct_alert_control"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Control direct notification-style vibration (experimental)" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="With optional system integration, direct notification-style vibration is silenced by default. Direct sound is not controlled yet."
|
|
||||||
android:textSize="12sp" />
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/rules"
|
android:id="@+id/rules"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ class AlertConfigurationJsonTest {
|
|||||||
)
|
)
|
||||||
val original = AlertConfiguration(
|
val original = AlertConfiguration(
|
||||||
profiles = listOf(profile),
|
profiles = listOf(profile),
|
||||||
appSettings = listOf(AlertAppSettings("chat.app", true, true)),
|
appSettings = listOf(AlertAppSettings("chat.app", true)),
|
||||||
rules = listOf(rule),
|
rules = listOf(rule),
|
||||||
queueSettings = AlertQueueSettings(7, AlertQueueOverflow.DROP_NEWEST),
|
queueSettings = AlertQueueSettings(7, AlertQueueOverflow.DROP_NEWEST),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package se.ajpanton.notificationsmaster.alerts
|
package se.ajpanton.notificationsmaster.alerts
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -28,46 +27,11 @@ class AlertRuleEvaluatorTest {
|
|||||||
assertEquals(listOf("first", "second"), result.decisions.map { it.ruleId })
|
assertEquals(listOf("first", "second"), result.decisions.map { it.ruleId })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun `direct notification alert is silent by default for opted in app`() {
|
|
||||||
val app = app.copy(directAlertControl = true)
|
|
||||||
val result = AlertRuleEvaluator.evaluate(
|
|
||||||
app,
|
|
||||||
emptyList(),
|
|
||||||
emptyMap(),
|
|
||||||
AlertEvent("chat.app", AlertSource.DIRECT_NOTIFICATION_SOUND),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertTrue(result.decisions.isEmpty())
|
|
||||||
assertTrue(result.silenceUnmatchedDirectAlert)
|
|
||||||
assertTrue(result.suppressesOriginal())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `direct notification alert is untouched without opt in`() {
|
|
||||||
val result = AlertRuleEvaluator.evaluate(
|
|
||||||
app,
|
|
||||||
emptyList(),
|
|
||||||
emptyMap(),
|
|
||||||
AlertEvent("chat.app", AlertSource.DIRECT_NOTIFICATION_SOUND),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertFalse(result.silenceUnmatchedDirectAlert)
|
|
||||||
assertFalse(result.suppressesOriginal())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `text and regex match only notification contents`() {
|
@Test fun `text and regex match only notification contents`() {
|
||||||
val rule = rule("match", 0, AlertTextMatcher(AlertTextField.BODY, AlertTextMode.REGEX, "invoice\\s+#\\d+"))
|
val rule = rule("match", 0, AlertTextMatcher(AlertTextField.BODY, AlertTextMode.REGEX, "invoice\\s+#\\d+"))
|
||||||
|
|
||||||
val match = evaluate(listOf(rule), AlertEvent("chat.app", AlertSource.NOTIFICATION_POST, body = "Invoice #42"))
|
val match = evaluate(listOf(rule), AlertEvent("chat.app", AlertSource.NOTIFICATION_POST, body = "Invoice #42"))
|
||||||
val direct = AlertRuleEvaluator.evaluate(
|
|
||||||
app.copy(directAlertControl = true),
|
|
||||||
listOf(rule.copy(sources = setOf(AlertSource.DIRECT_NOTIFICATION_SOUND))),
|
|
||||||
mapOf(profile.id to profile),
|
|
||||||
AlertEvent("chat.app", AlertSource.DIRECT_NOTIFICATION_SOUND),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(listOf("match"), match.decisions.map { it.ruleId })
|
assertEquals(listOf("match"), match.decisions.map { it.ruleId })
|
||||||
assertTrue(direct.decisions.isEmpty())
|
|
||||||
assertTrue(direct.silenceUnmatchedDirectAlert)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun `grouping ignores app assignment and local order`() {
|
@Test fun `grouping ignores app assignment and local order`() {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
|
||||||
|
|
||||||
<application android:theme="@style/Theme.AppCompat.DayNight">
|
<application android:theme="@style/Theme.AppCompat.DayNight">
|
||||||
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop">
|
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop">
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ import android.graphics.Bitmap
|
|||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.RingtoneManager
|
import android.media.RingtoneManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.VibrationAttributes
|
|
||||||
import android.os.VibrationEffect
|
|
||||||
import android.os.VibratorManager
|
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.ScrollView
|
import android.widget.ScrollView
|
||||||
@@ -72,7 +69,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
"chronometer" -> postChronometer()
|
"chronometer" -> postChronometer()
|
||||||
"timeout" -> postTimeout()
|
"timeout" -> postTimeout()
|
||||||
"group" -> postGroup()
|
"group" -> postGroup()
|
||||||
"direct_vibration" -> directNotificationVibration()
|
|
||||||
"cancel_text" -> manager.cancel(TEXT_ID)
|
"cancel_text" -> manager.cancel(TEXT_ID)
|
||||||
"cancel_all" -> manager.cancelAll()
|
"cancel_all" -> manager.cancelAll()
|
||||||
}
|
}
|
||||||
@@ -125,13 +121,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
manager.notify(GROUP_SUMMARY, base().setContentTitle("Grouped summary").setGroup(GROUP_KEY).setGroupSummary(true).build())
|
manager.notify(GROUP_SUMMARY, base().setContentTitle("Grouped summary").setGroup(GROUP_KEY).setGroupSummary(true).build())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun directNotificationVibration() {
|
|
||||||
getSystemService(VibratorManager::class.java).defaultVibrator.vibrate(
|
|
||||||
VibrationEffect.createWaveform(longArrayOf(0, 200), -1),
|
|
||||||
VibrationAttributes.Builder().setUsage(VibrationAttributes.USAGE_NOTIFICATION).build(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val CHANNEL = "test"
|
const val CHANNEL = "test"
|
||||||
const val EXTRA_ACTION = "helper_action"
|
const val EXTRA_ACTION = "helper_action"
|
||||||
@@ -156,7 +145,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
"Post very long text" to "very_long_text",
|
"Post very long text" to "very_long_text",
|
||||||
"Post inbox" to "inbox", "Post progress" to "progress", "Update progress" to "progress_update",
|
"Post inbox" to "inbox", "Post progress" to "progress", "Update progress" to "progress_update",
|
||||||
"Post chronometer" to "chronometer", "Post timeout" to "timeout", "Post group" to "group",
|
"Post chronometer" to "chronometer", "Post timeout" to "timeout", "Post group" to "group",
|
||||||
"Direct notification vibration" to "direct_vibration",
|
|
||||||
"Cancel text" to "cancel_text", "Cancel all" to "cancel_all",
|
"Cancel text" to "cancel_text", "Cancel all" to "cancel_all",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user