Skip to content

Commit 3de88ba

Browse files
committed
feat(webhook): add customizable HTTP method, query params, and payload template
1 parent f0b75ef commit 3de88ba

7 files changed

Lines changed: 244 additions & 20 deletions

File tree

.github/workflows/build.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ jobs:
3333
- name: Grant execute permission for gradlew
3434
run: chmod +x gradlew
3535

36+
- name: Clean build
37+
run: ./gradlew clean
38+
3639
- name: Decode Keystore
3740
run: |
3841
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > ${{ github.workspace }}/notif-forwarder-release.jks
@@ -45,6 +48,10 @@ jobs:
4548
-Pandroid.injected.signing.key.alias=${{ secrets.KEY_ALIAS }} \
4649
-Pandroid.injected.signing.key.password=${{ secrets.KEY_PASSWORD }}
4750
51+
- name: Cleanup Keystore
52+
if: always()
53+
run: rm -f ${{ github.workspace }}/notif-forwarder-release.jks
54+
4855
- name: Rename APK
4956
run: |
5057
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
@@ -86,3 +93,6 @@ jobs:
8693
prerelease: false
8794
env:
8895
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
96+
97+
permissions:
98+
contents: write

README.md

Lines changed: 73 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

@@ -71,6 +131,12 @@ Environment config (`webhook/.env`):
71131
<img src="screenshots/filter.jpg" alt="Filter" width="240" />
72132
<img src="screenshots/queue.jpg" alt="Queue" width="240" />
73133

134+
## Build
135+
136+
```bash
137+
./gradlew clean assembleDebug
138+
```
139+
74140
## License
75141

76142
This project is licensed under the MIT License.

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,

app/src/main/java/com/itsazni/notificationforwarder/network/WebhookClient.kt

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.itsazni.notificationforwarder.network
22

33
import com.google.gson.Gson
4+
import com.google.gson.JsonParser
45
import com.itsazni.notificationforwarder.data.QueueItem
6+
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
57
import okhttp3.MediaType.Companion.toMediaType
68
import okhttp3.OkHttpClient
79
import okhttp3.Request
@@ -30,23 +32,49 @@ class WebhookClient {
3032
url: String,
3133
method: String,
3234
headers: Map<String, String>,
35+
queryParams: Map<String, String>,
36+
payloadTemplate: String,
3337
item: QueueItem,
3438
deviceId: String
3539
): SendResult {
3640
return try {
37-
val payload = mapOf(
41+
val vars = mapOf(
3842
"deviceId" to deviceId,
39-
"packageName" to item.packageName,
40-
"appName" to item.appName,
41-
"title" to item.title,
42-
"text" to item.text,
43-
"postedAt" to item.postedAt,
44-
"notificationKey" to item.notificationKey
43+
"packageName" to escapeJson(item.packageName),
44+
"appName" to escapeJson(item.appName),
45+
"title" to escapeJson(item.title),
46+
"text" to escapeJson(item.text),
47+
"postedAt" to item.postedAt.toString(),
48+
"notificationKey" to escapeJson(item.notificationKey)
4549
)
46-
val bodyJson = gson.toJson(payload)
50+
51+
val finalUrl = buildUrl(url, queryParams)
52+
val bodyJson = if (payloadTemplate.isBlank()) {
53+
gson.toJson(
54+
mapOf(
55+
"deviceId" to deviceId,
56+
"packageName" to item.packageName,
57+
"appName" to item.appName,
58+
"title" to item.title,
59+
"text" to item.text,
60+
"postedAt" to item.postedAt,
61+
"notificationKey" to item.notificationKey
62+
)
63+
)
64+
} else {
65+
renderTemplate(payloadTemplate, vars)
66+
}
67+
68+
val isGet = method.equals("GET", ignoreCase = true)
4769
val requestBuilder = Request.Builder()
48-
.url(url)
49-
.method(method, bodyJson.toRequestBody("application/json".toMediaType()))
70+
.url(finalUrl)
71+
72+
if (isGet) {
73+
requestBuilder.get()
74+
} else {
75+
val contentType = headers["Content-Type"] ?: "application/json"
76+
requestBuilder.method(method.uppercase(), bodyJson.toRequestBody(contentType.toMediaType()))
77+
}
5078

5179
headers.forEach { (k, v) ->
5280
requestBuilder.addHeader(k, v)
@@ -64,4 +92,32 @@ class WebhookClient {
6492
SendResult(false, false, e.message ?: "network error")
6593
}
6694
}
95+
96+
private fun buildUrl(baseUrl: String, params: Map<String, String>): String {
97+
if (params.isEmpty()) return baseUrl
98+
val httpUrl = baseUrl.toHttpUrlOrNull() ?: return baseUrl
99+
val builder = httpUrl.newBuilder()
100+
params.forEach { (k, v) -> builder.addQueryParameter(k, v) }
101+
return builder.build().toString()
102+
}
103+
104+
private fun renderTemplate(template: String, vars: Map<String, String>): String {
105+
var result = template
106+
vars.forEach { (k, v) ->
107+
result = result.replace("{$k}", v)
108+
}
109+
// validate JSON to catch syntax errors early
110+
JsonParser.parseString(result)
111+
return result
112+
}
113+
114+
private fun escapeJson(text: String): String {
115+
return text
116+
.replace("\\", "\\\\")
117+
.replace("\"", "\\\"")
118+
.replace("\b", "\\b")
119+
.replace("\n", "\\n")
120+
.replace("\r", "\\r")
121+
.replace("\t", "\\t")
122+
}
67123
}

0 commit comments

Comments
 (0)