From ba32237a1496ee19cc62dcc4301d1d57fab190ec Mon Sep 17 00:00:00 2001 From: ItsAzni Date: Thu, 16 Apr 2026 20:57:59 +0700 Subject: [PATCH 1/2] fix(listener): deduplicate repeated notification callbacks --- .../service/AppNotificationListenerService.kt | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/app/src/main/java/com/itsazni/notificationforwarder/service/AppNotificationListenerService.kt b/app/src/main/java/com/itsazni/notificationforwarder/service/AppNotificationListenerService.kt index a100d62..8683ff1 100644 --- a/app/src/main/java/com/itsazni/notificationforwarder/service/AppNotificationListenerService.kt +++ b/app/src/main/java/com/itsazni/notificationforwarder/service/AppNotificationListenerService.kt @@ -1,6 +1,7 @@ package com.itsazni.notificationforwarder.service import android.app.Notification +import android.os.Build import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification import com.itsazni.notificationforwarder.data.NotificationRepository @@ -13,6 +14,15 @@ import kotlinx.coroutines.launch class AppNotificationListenerService : NotificationListenerService() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private data class RecentEvent( + val contentHash: Int, + val postedAt: Long, + val seenAt: Long + ) + + private val dedupLock = Any() + private val recentEvents = LinkedHashMap(MAX_RECENT_EVENTS, 0.75f, true) + override fun onNotificationPosted(sbn: StatusBarNotification?) { super.onNotificationPosted(sbn) val item = sbn ?: return @@ -24,6 +34,11 @@ class AppNotificationListenerService : NotificationListenerService() { val extras = notification.extras val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty() val text = extras?.getCharSequence(Notification.EXTRA_TEXT)?.toString().orEmpty() + val bigText = extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString().orEmpty() + + if (shouldSkip(item, notification, title, text, bigText)) { + return + } serviceScope.launch { val repository = NotificationRepository(applicationContext) @@ -46,4 +61,71 @@ class AppNotificationListenerService : NotificationListenerService() { pm.getApplicationLabel(applicationInfo).toString() }.getOrDefault(pkg) } + + private fun shouldSkip( + sbn: StatusBarNotification, + notification: Notification, + title: String, + text: String, + bigText: String + ): Boolean { + val isGroupSummary = (notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0 + if (isGroupSummary) { + return true + } + + val stableKey = buildStableKey(sbn) + val contentHash = listOf(title, text, bigText).joinToString("\u001f").hashCode() + val now = System.currentTimeMillis() + + synchronized(dedupLock) { + val previous = recentEvents[stableKey] + if (previous != null) { + val sameContent = previous.contentHash == contentHash + val samePostTime = previous.postedAt == sbn.postTime + val burstUpdate = now - previous.seenAt <= DUPLICATE_WINDOW_MS + if (sameContent && (samePostTime || burstUpdate)) { + return true + } + } + + recentEvents[stableKey] = RecentEvent( + contentHash = contentHash, + postedAt = sbn.postTime, + seenAt = now + ) + trimRecentEvents() + } + + return false + } + + private fun buildStableKey(sbn: StatusBarNotification): String { + val fallback = buildString { + append(sbn.packageName) + append('|') + append(sbn.id) + append('|') + append(sbn.tag ?: "") + append('|') + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + append(sbn.user.hashCode()) + } else { + append("legacy-user") + } + } + return sbn.key.ifBlank { fallback } + } + + private fun trimRecentEvents() { + while (recentEvents.size > MAX_RECENT_EVENTS) { + val firstKey = recentEvents.entries.firstOrNull()?.key ?: return + recentEvents.remove(firstKey) + } + } + + companion object { + private const val DUPLICATE_WINDOW_MS = 500L + private const val MAX_RECENT_EVENTS = 512 + } } From f0b75ef121528aff6f2d90c2456b0a79c138879e Mon Sep 17 00:00:00 2001 From: ItsAzni Date: Fri, 17 Apr 2026 08:33:05 +0700 Subject: [PATCH 2/2] feat: add GitHub Actions workflow to build, sign, and release APKs --- .github/workflows/build.yml | 88 +++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..48c6dc8 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,88 @@ +name: Build & Release APK + +on: + push: + tags: + - 'v*.*.*' + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +jobs: + build: + name: Build Signed Release APK + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Decode Keystore + run: | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > ${{ github.workspace }}/notif-forwarder-release.jks + + - name: Build Release APK + run: | + ./gradlew assembleRelease \ + -Pandroid.injected.signing.store.file=${{ github.workspace }}/notif-forwarder-release.jks \ + -Pandroid.injected.signing.store.password=${{ secrets.KEYSTORE_PASSWORD }} \ + -Pandroid.injected.signing.key.alias=${{ secrets.KEY_ALIAS }} \ + -Pandroid.injected.signing.key.password=${{ secrets.KEY_PASSWORD }} + + - name: Rename APK + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + VERSION="${{ github.ref_name }}" + else + BRANCH=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9._-]/-/g') + SHA=$(echo "${{ github.sha }}" | cut -c1-7) + VERSION="${BRANCH}-${SHA}" + fi + + APK_SRC="app/build/outputs/apk/release/app-release.apk" + APK_DEST="NotificationForwarder-${VERSION}.apk" + + cp "$APK_SRC" "$APK_DEST" + echo "APK_PATH=$APK_DEST" >> $GITHUB_ENV + echo "VERSION=$VERSION" >> $GITHUB_ENV + + - name: Upload APK as Artifact + uses: actions/upload-artifact@v4 + with: + name: NotificationForwarder-${{ env.VERSION }} + path: ${{ env.APK_PATH }} + retention-days: 30 + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + name: NotificationForwarder ${{ env.VERSION }} + body: | + ## NotificationForwarder ${{ env.VERSION }} + + ### Download + Download the APK below and install it on your Android device. + + > **Minimum Android:** 8.0 (API 26) + files: ${{ env.APK_PATH }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}