Skip to content

Commit f917c1f

Browse files
authored
Merge pull request #3 from ItsAzni/fix/notification-deduplication
Fix/notification deduplication
2 parents c158486 + f0b75ef commit f917c1f

2 files changed

Lines changed: 170 additions & 0 deletions

File tree

.github/workflows/build.yml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
name: Build & Release APK
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*.*.*'
7+
branches:
8+
- '**'
9+
pull_request:
10+
branches:
11+
- '**'
12+
workflow_dispatch:
13+
14+
jobs:
15+
build:
16+
name: Build Signed Release APK
17+
runs-on: ubuntu-latest
18+
19+
steps:
20+
- name: Checkout repository
21+
uses: actions/checkout@v4
22+
23+
- name: Set up JDK 17
24+
uses: actions/setup-java@v4
25+
with:
26+
java-version: '17'
27+
distribution: 'temurin'
28+
cache: gradle
29+
30+
- name: Setup Android SDK
31+
uses: android-actions/setup-android@v3
32+
33+
- name: Grant execute permission for gradlew
34+
run: chmod +x gradlew
35+
36+
- name: Decode Keystore
37+
run: |
38+
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > ${{ github.workspace }}/notif-forwarder-release.jks
39+
40+
- name: Build Release APK
41+
run: |
42+
./gradlew assembleRelease \
43+
-Pandroid.injected.signing.store.file=${{ github.workspace }}/notif-forwarder-release.jks \
44+
-Pandroid.injected.signing.store.password=${{ secrets.KEYSTORE_PASSWORD }} \
45+
-Pandroid.injected.signing.key.alias=${{ secrets.KEY_ALIAS }} \
46+
-Pandroid.injected.signing.key.password=${{ secrets.KEY_PASSWORD }}
47+
48+
- name: Rename APK
49+
run: |
50+
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
51+
VERSION="${{ github.ref_name }}"
52+
else
53+
BRANCH=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9._-]/-/g')
54+
SHA=$(echo "${{ github.sha }}" | cut -c1-7)
55+
VERSION="${BRANCH}-${SHA}"
56+
fi
57+
58+
APK_SRC="app/build/outputs/apk/release/app-release.apk"
59+
APK_DEST="NotificationForwarder-${VERSION}.apk"
60+
61+
cp "$APK_SRC" "$APK_DEST"
62+
echo "APK_PATH=$APK_DEST" >> $GITHUB_ENV
63+
echo "VERSION=$VERSION" >> $GITHUB_ENV
64+
65+
- name: Upload APK as Artifact
66+
uses: actions/upload-artifact@v4
67+
with:
68+
name: NotificationForwarder-${{ env.VERSION }}
69+
path: ${{ env.APK_PATH }}
70+
retention-days: 30
71+
72+
- name: Create GitHub Release
73+
if: startsWith(github.ref, 'refs/tags/')
74+
uses: softprops/action-gh-release@v2
75+
with:
76+
name: NotificationForwarder ${{ env.VERSION }}
77+
body: |
78+
## NotificationForwarder ${{ env.VERSION }}
79+
80+
### Download
81+
Download the APK below and install it on your Android device.
82+
83+
> **Minimum Android:** 8.0 (API 26)
84+
files: ${{ env.APK_PATH }}
85+
draft: false
86+
prerelease: false
87+
env:
88+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

app/src/main/java/com/itsazni/notificationforwarder/service/AppNotificationListenerService.kt

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.itsazni.notificationforwarder.service
22

33
import android.app.Notification
4+
import android.os.Build
45
import android.service.notification.NotificationListenerService
56
import android.service.notification.StatusBarNotification
67
import com.itsazni.notificationforwarder.data.NotificationRepository
@@ -13,6 +14,15 @@ import kotlinx.coroutines.launch
1314
class AppNotificationListenerService : NotificationListenerService() {
1415
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
1516

17+
private data class RecentEvent(
18+
val contentHash: Int,
19+
val postedAt: Long,
20+
val seenAt: Long
21+
)
22+
23+
private val dedupLock = Any()
24+
private val recentEvents = LinkedHashMap<String, RecentEvent>(MAX_RECENT_EVENTS, 0.75f, true)
25+
1626
override fun onNotificationPosted(sbn: StatusBarNotification?) {
1727
super.onNotificationPosted(sbn)
1828
val item = sbn ?: return
@@ -24,6 +34,11 @@ class AppNotificationListenerService : NotificationListenerService() {
2434
val extras = notification.extras
2535
val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
2636
val text = extras?.getCharSequence(Notification.EXTRA_TEXT)?.toString().orEmpty()
37+
val bigText = extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString().orEmpty()
38+
39+
if (shouldSkip(item, notification, title, text, bigText)) {
40+
return
41+
}
2742

2843
serviceScope.launch {
2944
val repository = NotificationRepository(applicationContext)
@@ -46,4 +61,71 @@ class AppNotificationListenerService : NotificationListenerService() {
4661
pm.getApplicationLabel(applicationInfo).toString()
4762
}.getOrDefault(pkg)
4863
}
64+
65+
private fun shouldSkip(
66+
sbn: StatusBarNotification,
67+
notification: Notification,
68+
title: String,
69+
text: String,
70+
bigText: String
71+
): Boolean {
72+
val isGroupSummary = (notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0
73+
if (isGroupSummary) {
74+
return true
75+
}
76+
77+
val stableKey = buildStableKey(sbn)
78+
val contentHash = listOf(title, text, bigText).joinToString("\u001f").hashCode()
79+
val now = System.currentTimeMillis()
80+
81+
synchronized(dedupLock) {
82+
val previous = recentEvents[stableKey]
83+
if (previous != null) {
84+
val sameContent = previous.contentHash == contentHash
85+
val samePostTime = previous.postedAt == sbn.postTime
86+
val burstUpdate = now - previous.seenAt <= DUPLICATE_WINDOW_MS
87+
if (sameContent && (samePostTime || burstUpdate)) {
88+
return true
89+
}
90+
}
91+
92+
recentEvents[stableKey] = RecentEvent(
93+
contentHash = contentHash,
94+
postedAt = sbn.postTime,
95+
seenAt = now
96+
)
97+
trimRecentEvents()
98+
}
99+
100+
return false
101+
}
102+
103+
private fun buildStableKey(sbn: StatusBarNotification): String {
104+
val fallback = buildString {
105+
append(sbn.packageName)
106+
append('|')
107+
append(sbn.id)
108+
append('|')
109+
append(sbn.tag ?: "")
110+
append('|')
111+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
112+
append(sbn.user.hashCode())
113+
} else {
114+
append("legacy-user")
115+
}
116+
}
117+
return sbn.key.ifBlank { fallback }
118+
}
119+
120+
private fun trimRecentEvents() {
121+
while (recentEvents.size > MAX_RECENT_EVENTS) {
122+
val firstKey = recentEvents.entries.firstOrNull()?.key ?: return
123+
recentEvents.remove(firstKey)
124+
}
125+
}
126+
127+
companion object {
128+
private const val DUPLICATE_WINDOW_MS = 500L
129+
private const val MAX_RECENT_EVENTS = 512
130+
}
49131
}

0 commit comments

Comments
 (0)