Skip to content

Commit b2f7e9e

Browse files
committed
Merge branch 'feat/custom-webhook-api' into 'master'
2 parents c158486 + aabb6e7 commit b2f7e9e

9 files changed

Lines changed: 408 additions & 20 deletions

File tree

.github/workflows/build.yml

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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: Clean build
37+
run: ./gradlew clean
38+
39+
- name: Decode Keystore
40+
run: |
41+
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > ${{ github.workspace }}/notif-forwarder-release.jks
42+
43+
- name: Build Release APK
44+
run: |
45+
./gradlew assembleRelease \
46+
-Pandroid.injected.signing.store.file=${{ github.workspace }}/notif-forwarder-release.jks \
47+
-Pandroid.injected.signing.store.password=${{ secrets.KEYSTORE_PASSWORD }} \
48+
-Pandroid.injected.signing.key.alias=${{ secrets.KEY_ALIAS }} \
49+
-Pandroid.injected.signing.key.password=${{ secrets.KEY_PASSWORD }}
50+
51+
- name: Cleanup Keystore
52+
if: always()
53+
run: rm -f ${{ github.workspace }}/notif-forwarder-release.jks
54+
55+
- name: Rename APK
56+
run: |
57+
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
58+
VERSION="${{ github.ref_name }}"
59+
else
60+
BRANCH=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9._-]/-/g')
61+
SHA=$(echo "${{ github.sha }}" | cut -c1-7)
62+
VERSION="${BRANCH}-${SHA}"
63+
fi
64+
65+
APK_SRC="app/build/outputs/apk/release/app-release.apk"
66+
APK_DEST="NotificationForwarder-${VERSION}.apk"
67+
68+
cp "$APK_SRC" "$APK_DEST"
69+
echo "APK_PATH=$APK_DEST" >> $GITHUB_ENV
70+
echo "VERSION=$VERSION" >> $GITHUB_ENV
71+
72+
- name: Upload APK as Artifact
73+
uses: actions/upload-artifact@v4
74+
with:
75+
name: NotificationForwarder-${{ env.VERSION }}
76+
path: ${{ env.APK_PATH }}
77+
retention-days: 30
78+
79+
- name: Create GitHub Release
80+
if: startsWith(github.ref, 'refs/tags/')
81+
uses: softprops/action-gh-release@v2
82+
with:
83+
name: NotificationForwarder ${{ env.VERSION }}
84+
body: |
85+
## NotificationForwarder ${{ env.VERSION }}
86+
87+
### Download
88+
Download the APK below and install it on your Android device.
89+
90+
> **Minimum Android:** 8.0 (API 26)
91+
files: ${{ env.APK_PATH }}
92+
draft: false
93+
prerelease: false
94+
env:
95+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
96+
97+
permissions:
98+
contents: write

README.md

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ Android app to listen for incoming notifications and forward them to a configura
55
## Features
66

77
- Notification capture using `NotificationListenerService`
8-
- Webhook forwarding with configurable URL, auth mode, and custom headers
8+
- Webhook forwarding with configurable URL, HTTP method, auth mode, custom headers, query params, and payload template
9+
- Compatible with Telegram Bot API, Discord webhooks, and any custom API
910
- Queue system with Room (durable local storage)
1011
- Retry system with WorkManager (network constraints + backoff)
1112
- Background support
@@ -24,6 +25,63 @@ Android app to listen for incoming notifications and forward them to a configura
2425
./gradlew assembleDebug
2526
```
2627

28+
## Webhook Configuration
29+
30+
### Supported HTTP Methods
31+
- `GET` — no request body, query params appended to URL
32+
- `POST` — with JSON body
33+
- `PUT` — with JSON body
34+
- `PATCH` — with JSON body
35+
36+
### Authentication
37+
- **None** — no auth header
38+
- **Bearer** — adds `Authorization: Bearer <token>`
39+
- **Custom** — define any headers manually
40+
41+
### Custom Query Params
42+
Add per line as `key=value`:
43+
```
44+
chat_id=123456789
45+
token=abc123
46+
```
47+
48+
### Custom Payload Template
49+
Use JSON with variable placeholders. Leave blank for default payload.
50+
51+
Available variables:
52+
- `{deviceId}`
53+
- `{packageName}`
54+
- `{appName}`
55+
- `{title}`
56+
- `{text}`
57+
- `{postedAt}`
58+
- `{notificationKey}`
59+
60+
#### Example: Telegram Bot API
61+
- URL: `https://api.telegram.org/bot<token>/sendMessage`
62+
- Method: `POST`
63+
- Payload template:
64+
```json
65+
{"chat_id":"123456789","text":"*{appName}*\n*{title}*\n{text}","parse_mode":"Markdown"}
66+
```
67+
68+
#### Example: Discord Webhook
69+
- URL: `https://discord.com/api/webhooks/.../...`
70+
- Method: `POST`
71+
- Payload template:
72+
```json
73+
{"content":"**{appName}**\n**{title}**\n{text}"}
74+
```
75+
76+
#### Example: Custom GET API
77+
- URL: `https://example.com/api/alert`
78+
- Method: `GET`
79+
- Query params:
80+
```
81+
device={deviceId}
82+
msg={title}
83+
```
84+
2785
## Local Webhook API (`webhook/`)
2886

2987
This repository includes a Node.js webhook receiver in `webhook/` for local testing.
@@ -52,12 +110,14 @@ Health check:
52110

53111
Environment config (`webhook/.env`):
54112

55-
- `HOST`
56-
- `PORT`
57-
- `WEBHOOK_PATH`
58-
- `WEBHOOK_BEARER_TOKEN`
59-
- `WEBHOOK_LOG_FILE`
60-
- `JSON_LIMIT`
113+
| Key | Description |
114+
|-----|-------------|
115+
| `HOST` | Server host |
116+
| `PORT` | Server port |
117+
| `WEBHOOK_PATH` | Webhook endpoint path |
118+
| `WEBHOOK_BEARER_TOKEN` | Optional bearer token |
119+
| `WEBHOOK_LOG_FILE` | Log file path |
120+
| `JSON_LIMIT` | Max JSON body size |
61121

62122
## Screenshots
63123

app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ android {
1313
minSdk = 26
1414
targetSdk = 36
1515
versionCode = 1
16-
versionName = "1.0"
16+
versionName = "1.1.0"
1717

1818
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
1919
}

app/src/main/java/com/itsazni/notificationforwarder/MainActivity.kt

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,25 +83,31 @@ private enum class AppTab(val label: String, val icon: ImageVector) {
8383

8484
private data class UiSettings(
8585
val webhookUrl: String,
86+
val webhookMethod: String,
8687
val forwardingEnabled: Boolean,
8788
val filterMode: FilterMode,
8889
val filterPackagesRaw: String,
8990
val authMode: AuthMode,
9091
val bearerToken: String,
9192
val customHeadersRaw: String,
93+
val queryParamsRaw: String,
94+
val payloadTemplateRaw: String,
9295
val maxRetriesRaw: String,
9396
val batchSizeRaw: String
9497
)
9598

9699
private fun AppSettings.toUiSettings(): UiSettings {
97100
return UiSettings(
98101
webhookUrl = webhookUrl,
102+
webhookMethod = webhookMethod,
99103
forwardingEnabled = forwardingEnabled,
100104
filterMode = filterMode,
101105
filterPackagesRaw = filterPackages.joinToString("\n"),
102106
authMode = authMode,
103107
bearerToken = bearerToken,
104108
customHeadersRaw = customHeadersRaw,
109+
queryParamsRaw = queryParamsRaw,
110+
payloadTemplateRaw = payloadTemplateRaw,
105111
maxRetriesRaw = maxRetries.toString(),
106112
batchSizeRaw = batchSize.toString()
107113
)
@@ -179,12 +185,14 @@ private fun MainScreen(settingsStore: SettingsStore) {
179185
val result = withContext(Dispatchers.IO) {
180186
WebhookClient().send(
181187
url = uiSettings.webhookUrl,
182-
method = "POST",
188+
method = uiSettings.webhookMethod,
183189
headers = buildHeadersPreview(
184190
authMode = uiSettings.authMode,
185191
token = uiSettings.bearerToken,
186192
customHeadersRaw = uiSettings.customHeadersRaw
187193
),
194+
queryParams = parseKeyValuePairs(uiSettings.queryParamsRaw),
195+
payloadTemplate = uiSettings.payloadTemplateRaw,
188196
item = QueueItem(
189197
packageName = "com.test.package",
190198
appName = "Webhook Test",
@@ -383,6 +391,15 @@ private fun WebhookScreen(
383391
singleLine = true
384392
)
385393

394+
DropdownSelector(
395+
label = "HTTP method",
396+
value = uiSettings.webhookMethod,
397+
options = listOf("GET", "POST", "PUT", "PATCH"),
398+
onSelected = {
399+
onSettingsChange(uiSettings.copy(webhookMethod = it))
400+
}
401+
)
402+
386403
DropdownSelector(
387404
label = "Auth mode",
388405
value = uiSettings.authMode.name,
@@ -410,6 +427,24 @@ private fun WebhookScreen(
410427
label = { Text("Custom headers (Key: Value per line)") }
411428
)
412429

430+
OutlinedTextField(
431+
modifier = Modifier
432+
.fillMaxWidth()
433+
.height(140.dp),
434+
value = uiSettings.queryParamsRaw,
435+
onValueChange = { onSettingsChange(uiSettings.copy(queryParamsRaw = it)) },
436+
label = { Text("Query params (key=value per line)") }
437+
)
438+
439+
OutlinedTextField(
440+
modifier = Modifier
441+
.fillMaxWidth()
442+
.height(200.dp),
443+
value = uiSettings.payloadTemplateRaw,
444+
onValueChange = { onSettingsChange(uiSettings.copy(payloadTemplateRaw = it)) },
445+
label = { Text("Payload template (JSON with {title} {text} etc.)") }
446+
)
447+
413448
Button(modifier = Modifier.fillMaxWidth(), onClick = onSave) {
414449
Text("Save Webhook Settings")
415450
}
@@ -639,12 +674,15 @@ private fun QueueStatCard(
639674

640675
private fun saveSettings(settingsStore: SettingsStore, uiSettings: UiSettings) {
641676
settingsStore.webhookUrl = uiSettings.webhookUrl
677+
settingsStore.webhookMethod = uiSettings.webhookMethod
642678
settingsStore.forwardingEnabled = uiSettings.forwardingEnabled
643679
settingsStore.filterMode = uiSettings.filterMode
644680
settingsStore.filterPackages = SettingsStore.parsePackages(uiSettings.filterPackagesRaw)
645681
settingsStore.authMode = uiSettings.authMode
646682
settingsStore.bearerToken = uiSettings.bearerToken
647683
settingsStore.customHeadersRaw = uiSettings.customHeadersRaw
684+
settingsStore.queryParamsRaw = uiSettings.queryParamsRaw
685+
settingsStore.payloadTemplateRaw = uiSettings.payloadTemplateRaw
648686
settingsStore.maxRetries = uiSettings.maxRetriesRaw.toIntOrNull() ?: 10
649687
settingsStore.batchSize = uiSettings.batchSizeRaw.toIntOrNull() ?: 20
650688
}
@@ -658,6 +696,21 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
658696
return enabled.contains(target.flattenToString())
659697
}
660698

699+
private fun parseKeyValuePairs(raw: String): Map<String, String> {
700+
val map = linkedMapOf<String, String>()
701+
raw.lines().forEach { line ->
702+
val trimmed = line.trim()
703+
if (trimmed.isEmpty()) return@forEach
704+
val idx = trimmed.indexOf('=')
705+
if (idx > 0) {
706+
val key = trimmed.substring(0, idx).trim()
707+
val value = trimmed.substring(idx + 1).trim()
708+
if (key.isNotEmpty()) map[key] = value
709+
}
710+
}
711+
return map
712+
}
713+
661714
private fun buildHeadersPreview(
662715
authMode: AuthMode,
663716
token: String,

0 commit comments

Comments
 (0)