Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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 }} \
Comment on lines +40 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate signed release build when secrets are unavailable

This workflow runs on pull_request, but the release build always injects signing credentials from secrets.*. On pull requests from forks, GitHub does not provide repository secrets, so these values are empty and assembleRelease tries to sign with an invalid/empty keystore, causing the CI job to fail for external contributors. Add a condition to skip signed-release steps (or build unsigned) when secrets are not available.

Useful? React with 👍 / 👎.

-Pandroid.injected.signing.key.alias=${{ secrets.KEY_ALIAS }} \
-Pandroid.injected.signing.key.password=${{ secrets.KEY_PASSWORD }}
Comment on lines +36 to +46
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The keystore decode step runs for every push/PR, but secrets are not available for forked PRs (and may be intentionally unset in some environments), which will cause this workflow to fail before any build happens. Consider gating keystore decode/signing to tag builds (or when all required secrets are present), and running an unsigned/debug build for PRs/branch pushes instead.

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +46
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow uses the "android.injected.signing.*" properties to sign the release. That approach is intended for IDE/injected builds and can be brittle in CI (and may break with future AGP updates). Prefer configuring signingConfigs in Gradle and selecting the signing config via environment variables/Gradle properties, or use a dedicated Gradle task for CI signing.

Suggested change
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 }}
env:
ORG_GRADLE_PROJECT_signingStoreFile: ${{ github.workspace }}/notif-forwarder-release.jks
ORG_GRADLE_PROJECT_signingStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
ORG_GRADLE_PROJECT_signingKeyAlias: ${{ secrets.KEY_ALIAS }}
ORG_GRADLE_PROJECT_signingKeyPassword: ${{ secrets.KEY_PASSWORD }}
run: |
./gradlew assembleRelease

Copilot uses AI. Check for mistakes.

- 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 }}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String, RecentEvent>(MAX_RECENT_EVENTS, 0.75f, true)

override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
val item = sbn ?: return
Expand All @@ -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)
Expand All @@ -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()
Comment on lines +77 to +79
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contentHash is derived from String.hashCode() over a concatenated string, which can collide and incorrectly suppress distinct notifications. If the goal is correctness over micro-optimizations, consider using a stronger/fewer-collisions content signature (e.g., a 64-bit hash or MessageDigest) and avoid the intermediate List/joined String allocation.

Copilot uses AI. Check for mistakes.

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)) {
Comment on lines +79 to +87
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dedup window uses System.currentTimeMillis() for seenAt and then compares deltas. Wall-clock time can jump backwards/forwards (manual change, NTP), which can break the burstUpdate calculation and lead to missed or extra forwarding. Consider using a monotonic clock (e.g., SystemClock.elapsedRealtime()) for seenAt/window comparisons while keeping sbn.postTime as-is.

Copilot uses AI. Check for mistakes.
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")
}
Comment on lines +111 to +115
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build.VERSION.SDK_INT >= JELLY_BEAN_MR1 is always true in this app (minSdk is 26), so the else branch is dead code. Consider simplifying this to always include sbn.user (or removing the version check entirely) to reduce noise in the key calculation.

Copilot uses AI. Check for mistakes.
}
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
}
}
Loading