- JavaScript 69.3%
- Kotlin 21.2%
- Shell 6.4%
- Dockerfile 3.1%
- JobQueue class: single-worker, in-memory FIFO queue - All Appium endpoints now return 202 + jobId (instead of blocking) - GET /jobs/:jobId + GET /jobs for status polling - Webhook receive stays instant (200), media-pull goes via queue - Upload file cleanup via promise.finally() - Logging: queue add/start/done/fail with timestamps - README updated with queue architecture and migration guide |
||
|---|---|---|
| android-notification-listener | ||
| wa-appium-api | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| entrypoint.sh | ||
| README.md | ||
| smoke-test.js | ||
WhatsApp ADB Container
Appium 3 + UiAutomator2-driver in Docker, voor geautomatiseerde aansturing van een fysieke Android-telefoon via ADB. Ontworpen voor de Nexus 6P (niet geroot) met WhatsApp.
Twee containers:
| Container | Functie | Poort |
|---|---|---|
appium-adb |
ADB-server + Appium | :4723 |
wa-appium-api |
Express REST API (fallback voor wwebjs-api) | :3010 |
Architectuur
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ appium-adb │ │ wa-appium-api │
│ │ │ │
│ entrypoint.sh: ────────────┤ │ server.js: │
│ adb start-server │ │ Express REST API │
│ adb connect/usb │ │ WebDriverIO → Appium │
│ wifi_sleep_policy=2 │ │ /client/getChats/:ses │
│ svc power stayon usb │ │ /chat/fetchMessages/:ses │
│ dumpsys deviceidle off │ │ /client/sendMessage/:ses │
│ reconnect loop (30s) │ │ │
│ appium server :4723 ─────┼─────┤ localhost:4723 │
│ │ │ │
└─────────────────────────────┘ └─────────────────────────────┘
│ USB │ REST (localhost:3010)
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Nexus 6P │ │ Clients │
│ (ENU7N15B…) │ │ (scripts, │
│ WhatsApp │ │ curl, etc) │
└──────────────┘ └──────────────┘
Fallback-strategie: De primaire WhatsApp-laag is Whatsapp rest-api (:3001)
via WhatsApp Web. Als die sessie wegvalt (geblokkeerd nummer, browser-crash), springt
wa-appium-api (:3010) in — die de telefoon direct via USB aanstuurt.
Snelstart
USB-modus (telefoon aan de kabel)
Zet USB debugging aan op de telefoon (Settings > Developer options > USB debugging).
# check of de telefoon zichtbaar is
adb devices
# → ENU7N15B14000616 device
# start beide containers
docker compose up -d
# smoke test
node smoke-test.js
# health check
curl http://127.0.0.1:3010/health
TCP/IP-modus (telefoon remote)
# eenmalig na elke reboot (kabel nodig)
adb usb && adb tcpip 5555
# kabel los, start met PHONE_IP
PHONE_IP=192.168.5.100 docker compose up -d
Environment variabelen
appium-adb
| Variabele | Default | Beschrijving |
|---|---|---|
PHONE_IP |
(leeg) | IP voor TCP/IP-modus. Leeg = USB-modus |
ADB_PORT |
5555 |
ADB-poort (na adb tcpip <poort>) |
wa-appium-api
| Variabele | Default | Beschrijving |
|---|---|---|
APPIUM_HOST |
127.0.0.1 |
Appium server host |
APPIUM_PORT |
4723 |
Appium server poort |
PORT |
3010 |
REST API luisterpoort |
Kopieer .env.example naar .env om defaults aan te passen.
USB-passthrough
De compose mount /dev/bus/usb in de appium-adb container. De container draait als root,
dus USB-devices zijn direct toegankelijk. Het ADB-serverproces detecteert USB-hotplug
automatisch.
Eerste keer op een nieuwe host:
- udev-regel aanmaken:
SUBSYSTEM=="usb", ATTR{idVendor}=="18d1", MODE="0660", GROUP="plugdev" - Gebruiker toevoegen aan
plugdev-groep - Kabel los/koppelen of
chown root:plugdev /dev/bus/usb/XXX/YYY - RSA-vingerafdruk accepteren op de telefoon ("Altijd toestaan")
wa-appium-api (fallback)
REST API die wwebjs-api-endpoints spiegelt, maar via Appium de Android-telefoon aanstuurt in plaats van WhatsApp Web. Trager en layout-gevoelig, maar werkt ook als de Web-sessie geblokkeerd is.
Endpoints
GET /health
curl http://127.0.0.1:3010/health
{"status":"ok","sessionId":"..."}
GET /client/getChats/:session
Haalt alle chats op van het WhatsApp-hoofdscherm.
curl http://127.0.0.1:3010/client/getChats/EdGpt
{
"chats": [
{
"name": "+31 6 12345678",
"lastMessage": "Foto",
"timestamp": "17:28",
"unread": 0
},
...
]
}
| Veld | Type | Beschrijving |
|---|---|---|
name |
string | Contactnaam of telefoonnummer |
lastMessage |
string | Laatste bericht preview (leeg bij media) |
timestamp |
string | Tijdstip laatste bericht |
unread |
int | Aantal ongelezen berichten |
Limitaties: alleen zichtbare chats op het scherm. Scrollen wordt nog niet ondersteund.
Media-berichten tonen lege preview of NaN.
POST /chat/fetchMessages/:session
Haalt berichten op uit een specifieke chat.
curl -X POST http://127.0.0.1:3010/chat/fetchMessages/EdGpt \
-H 'Content-Type: application/json' \
-d '{"chatId": "31612345678"}'
{
"messages": [
{
"text": "Hallo!",
"timestamp": "10:00"
},
...
]
}
| Parameter | Type | Verplicht | Beschrijving |
|---|---|---|---|
chatId |
string | ja | Telefoonnummer (met of zonder @c.us) of contactnaam |
Limitaties: alleen tekstberichten worden opgehaald (geen media, voice, of system messages). Alleen zichtbare berichten in de weergave (scrollt niet).
POST /client/sendMessage/:session
Verstuurt een tekstbericht.
curl -X POST http://127.0.0.1:3010/client/sendMessage/EdGpt \
-H 'Content-Type: application/json' \
-d '{
"chatId": "31612345678",
"content": "Hallo vanuit de API!"
}'
{"status":"sent","content":"Hallo vanuit de API!"}
| Parameter | Type | Verplicht | Beschrijving |
|---|---|---|---|
chatId |
string | ja | Telefoonnummer (zonder @c.us) of contactnaam |
content |
string | ja | Berichttekst |
Werkt met: individuele chats. Groepsberichten nog niet getest.
Hoe het werkt (intern)
De API gebruikt WebDriverIO om via Appium de WhatsApp Android-app aan te sturen:
getChats: startActivity → lees contact_row_container elementen → parse naam/bericht/tijd/ongelezen
openChat: startActivity → klik search_bar_inner_layout → type in search_input → klik contact_row_container
sendMessage: openChat → setValue in entry → klik send → goHome
fetchMessages: openChat → lees main_layout → message_text + date
Gebruikte resource-IDs (WhatsApp v2.25+, kunnen wijzigen per versie):
| Resource-ID | Functie |
|---|---|
com.whatsapp:id/search_bar_inner_layout |
Zoekbalk op home |
com.whatsapp:id/search_input |
Zoekveld |
com.whatsapp:id/contact_row_container |
Chatrij in lijst |
com.whatsapp:id/conversations_row_contact_name |
Contactnaam |
com.whatsapp:id/single_msg_tv |
Bericht preview (individueel) |
com.whatsapp:id/msg_from_tv |
Bericht preview (community/groep) |
com.whatsapp:id/conversations_row_date |
Tijdstip |
com.whatsapp:id/conversations_row_unread_indicator |
Ongelezen indicator |
com.whatsapp:id/entry |
Tekstinvoerveld |
com.whatsapp:id/send |
Verzendknop |
com.whatsapp:id/main_layout |
Berichtcontainer in chat |
com.whatsapp:id/message_text |
Berichttekst |
com.whatsapp:id/date |
Datum/tijd van bericht |
Media-ondersteuning (foto's)
Verzenden: POST /messages/send-media
Verstuurt een foto via WhatsApp. Gebruikt een ADB-intent om het deelscherm te openen, daarna Appium voor de contactkeuze en versturen.
curl -X POST http://127.0.0.1:3010/messages/send-media \
-F 'chatName=+31 6 12345678' \
-F 'caption=Optioneel bijschrift' \
-F 'image=@/pad/naar/foto.jpg'
| Veld | Type | Verplicht | Beschrijving |
|---|---|---|---|
image |
file | ja | JPEG/PNG/WEBP, max 20MB |
chatName |
string | ja | Contactnaam of telefoonnummer |
caption |
string | nee | Optioneel bijschrift bij de foto |
Flow:
- Bestand wordt geüpload naar de API-container
adb pushnaar/sdcard/Pictures/op de telefoonadb shell am startmetACTION.SENDintent naar WhatsApp- Appium: contact zoeken en selecteren in het deelscherm
- Caption typen (indien opgegeven)
- Send-knop klikken
- Tijdelijke bestanden opruimen (telefoon + container)
Let op: de contact picker in het deelscherm gebruikt mogelijk andere resource-IDs dan de normale WhatsApp-zoekbalk. Bij wijzigingen in WhatsApp kan dit endpoint breken.
Ontvangen (notification bridge → API)
De Android notification-listener detecteert media-notificaties door te
checken of de berichttekst leeg is of alleen emoji bevat. Bij een media-
notificatie stuurt de app mediaType: "image" mee in de webhook-payload.
De API verwerkt dit als volgt:
- Webhook binnen met
mediaType: "image" - API wacht
MEDIA_WAIT_MSms (default 8s) zodat WhatsApp de download kan voltooien adb shell ls -tinWhatsApp Images/— zoekt het nieuwste bestandadb pullvan dat bestand naar de container- Opslaan met een uniek token, beschikbaar via
/download/:token - Fanout naar subscribers met
mediaUrl+mediaFilenamein de payload
# test: verstuur een kunstmatig media-event
curl -X POST http://127.0.0.1:3010/webhooks/whatsapp/incoming \
-H 'Content-Type: application/json' \
-H 'X-Webhook-Secret: verander-mij' \
-d '{
"sender":"+31 6 12345678",
"text":"",
"chatId":"Test Chat",
"mediaType":"image",
"timestamp": 1700000000000
}'
Bekende beperkingen (media)
- Race condition: als kort na elkaar meerdere foto's in verschillende
chats binnenkomen, kan de "nieuwste bestand"-detectie de verkeerde
foto pakken. Mitigatie: verleng
MEDIA_WAIT_MSof implementeer bestandsgrootte/hash-vergelijking. - Alleen WhatsApp Images: de huidige implementatie kijkt alleen in
/sdcard/Android/media/com.whatsapp/WhatsApp/Media/WhatsApp Images/. Video's, spraakberichten en andere media worden niet ondersteund. - Android-versie-specifiek: de paden naar de WhatsApp-media-map kunnen wijzigen per Android-versie. Geverifieerd op Nexus 6P met Android 8.
- Alleen push-notificaties: media die binnenkomt terwijl de app open is (geen notificatie) wordt niet opgevangen.
- Verouderde bestanden: bij een trage WhatsApp-download kan het
gebeuren dat het bestand nog niet op de telefoon staat wanneer de API
het zoekt. Verleng
MEDIA_WAIT_MSin dat geval.
Containers in detail
appium-adb
Image build:
- Base:
node:lts-bookworm-slim - OpenJDK 17 + Android platform-tools (Google) + Appium 3.x + uiautomator2-driver
- Zie
Dockerfile
Entrypoint (entrypoint.sh):
adb start-server- Detecteer USB (
adb -d) of TCP (adb connect) - Pas instellingen toe (non-root):
wifi_sleep_policy=2,svc power stayon usb,deviceidle disable - Start reconnect loop op achtergrond (elke 30s check of device nog zichtbaar is)
exec appium --address 0.0.0.0 --port 4723 --allow-cors --relaxed-security
wa-appium-api
Image build:
- Base:
node:lts-bookworm-slim - Express + WebDriverIO
- Zie
wa-appium-api/Dockerfile
Belangrijke code in wa-appium-api/server.js:
getDriver()— maakt Appium-sessie, herconnect bij dropensureWhatsApp(d)— start WhatsApp als het niet op de voorgrond staatgoHome(d)— navigeer naar WhatsApp homeopenChat(d, query)— zoek chat via search, open 'm- Finders gebruiken UiAutomator2
new UiSelector()via WebDriverIO
Job queue (FIFO, concurrency=1): Alle Appium/ADB-operaties gaan door één centrale queue, omdat er maar één fysieke telefoon is. Geen twee acties tegelijk op dezelfde UI-sessie.
- Aanbieding: endpoints geven meteen
202 { jobId }terug, job wordt achter de schermen verwerkt - Status:
GET /jobs/:jobId— pending / in_progress / done / failed - Overzicht:
GET /jobs— laatste 50 jobs - Webhook: ontvangst gaat direct (200), alleen media-pull gaat via queue
- Retry: bij falen blijft de job op
failed. De aanroeper moet zelf opnieuw proberen. - Logging:
[queue] +jobId (label) — N jobs waiting, dan▶en✔/✗ - Breaking change:
getChatsenfetchMessageswaren synchroon (data direct in response). Nu geven ze ook202 + jobId. PollGET /jobs/:jobIdtotdoneen checkresultvoor de data.
# voorbeeld: job aanmaken, status pollen, resultaat uitlezen
JOB=$(curl -s http://localhost:3010/client/getChats/EdGpt | python3 -c "import sys,json; print(json.load(sys.stdin)['jobId'])")
sleep 5
curl -s http://localhost:3010/jobs/$JOB | python3 -m json.tool
Troubleshooting
"Could not find a connected Android device"
docker logs appium-adb — check of adb connect/usb lukt
- Zit de USB-kabel er goed in? Check met
adb devicesop de host. - USB debugging aan op de telefoon? (Developer options)
- Nieuwe RSA-key? Kabel los/koppelen en "Altijd toestaan" op telefoon.
- Voor TCP/IP:
adb connect <IP>:5555testen vanaf host.
ADB-serverversie-mismatch
ADB in de container (Google platform-tools) en op de host kunnen verschillen. Meestal geen probleem. Bij vreemd gedrag:
# host ADB updaten
sudo -A dnf install android-tools # Fedora
# of gebruik ADB uit container
docker exec appium-adb adb devices
Appium vindt uiautomator2-driver niet
driver uiautomator2 is not installed
docker exec appium-adb appium driver install uiautomator2
# of rebuild
docker compose build --no-cache appium
Telefoon niet meer bereikbaar na reboot
Na een reboot moet USB debugging opnieuw bevestigd worden (RSA-vingerafdruk):
adb devices
# zo niet: kabel los/koppelen, bevestig op telefoon
# voor TCP/IP: adb usb && adb tcpip 5555
Verbinding valt weg na verloop van tijd
De entrypoint past toe:
settings put global wifi_sleep_policy 2
svc power stayon usb
dumpsys deviceidle disable
Bij USB-verbinding zou dit zelden moeten gebeuren. Check:
docker logs appium-adb— zie je reconnect-pogingen?- Kabel: probeer een andere. Goedkope kabels geven drops.
- USB-poort: direct op moederbord, niet via hub.
- Stay awake: zet aan in Developer options op de telefoon.
- Settings opnieuw:
docker restart appium-adb. - Laatste redmiddel: rooten +
iw wlan0 set power_save off(alleen TCP/IP).
wa-appium-api fault
docker logs wa-appium-api
# als server.js is aangepast, rebuild nodig:
docker compose build wa-appium-api
docker compose up -d wa-appium-api
wa-appium-api: session lost
De getDriver() functie herconnect automatisch bij een verbroken sessie.
Als dat niet werkt:
docker restart wa-appium-api
Android Notification Bridge (event-driven)
Dit systeem maakt real-time notificatie van binnenkomende WhatsApp-berichten mogelijk zonder te pollen via Appium. Het bestaat uit twee componenten:
WhatsApp → Android Notification → listener-app (APK) → HTTP POST → wa-appium-api → subscribers
Architectuur
- Android app (
android-notification-listener/) luistert naar WhatsApp-notificaties - Bij elk bericht stuurt de app een HTTP POST naar de API-container
- De API valideert het gedeelde secret en stuurt het bericht door naar subscribers
- Subscribers registreren zich via een apart endpoint
Android-app: bouwen en installeren
Builden (vereist Android SDK lokaal of op een build-server):
cd android-notification-listener
./gradlew assembleDebug
adb install app/build/outputs/apk/debug/app-debug.apk
Als gradlew niet bestaat, genereer het eerst:
gradle wrapper --gradle-version 8.5
Notificatietoegang verlenen (eenmalig via ADB):
adb shell settings put secure enabled_notification_listeners \
com.eddydevink.whatsapplistener/com.eddydevink.whatsapplistener.WhatsAppNotificationListener
Verifiëren:
adb shell settings get secure enabled_notification_listeners
# moet de package/service naam bevatten
Configureren: open de app op de telefoon ("WA Notification Bridge"), vul het webhook-URL en shared secret in.
Webhook configuratie (API-zijde)
Zet in .env of als environment variable:
WEBHOOK_SHARED_SECRET=verander-mij # moet gelijk zijn aan de Android-app
Webhook endpoints
POST /webhooks/whatsapp/incoming
Ontvangt berichten van de Android notification-listener.
curl -X POST http://127.0.0.1:3010/webhooks/whatsapp/incoming \
-H 'Content-Type: application/json' \
-H 'X-Webhook-Secret: verander-mij' \
-d '{
"sender": "+31 6 12345678",
"text": "Hallo!",
"chatId": "+31 6 12345678",
"timestamp": 1700000000000
}'
| Header | Waarde |
|---|---|
X-Webhook-Secret |
Gedeeld geheim (moet matchen met WEBHOOK_SHARED_SECRET) |
| Veld | Type | Verplicht | Beschrijving |
|---|---|---|---|
sender |
string | ja | Naam of nummer van afzender |
text |
string | ja | Berichttekst |
chatId |
string | ja | Chat-naam zoals getoond in notificatie |
timestamp |
long | nee | Unix timestamp (ms) |
POST /webhooks/subscribe
Registreer een subscriber (interne service die notificaties wil ontvangen).
curl -X POST http://127.0.0.1:3010/webhooks/subscribe \
-H 'Content-Type: application/json' \
-d '{"url": "http://192.168.5.252:3003/webhook"}'
GET /webhooks/subscribers
Lijst van actieve subscribers.
curl http://127.0.0.1:3010/webhooks/subscribers
Beperkingen
- Samengevoegde notificaties: als meerdere berichten in dezelfde chat
binnenkomen voordat de eerste is verwerkt, kan Android één samengevoegde
notificatie sturen. De listener verwerkt dan alle
messagesuit deMessagingStyle, dus individuele berichten gaan niet verloren. - Alleen push-notificaties: berichten die binnenkomen terwijl de app open is (dus geen notificatie genereren) worden niet gevangen. Gebruik dan Appium-polling als aanvulling.
- Eén telefoon, één listener: de app luistert naar alle chats.
Onderhoud
Rebuild en restart
# alles
docker compose build --no-cache
docker compose up -d
# alleen Appium (bv. na driver update)
docker compose build --no-cache appium
docker compose up -d appium
# alleen API (bv. na code wijziging)
docker compose build wa-appium-api
docker compose up -d wa-appium-api
Logs
docker logs appium-adb -f
docker logs wa-appium-api -f
Phone settings resetten
docker exec appium-adb adb shell settings put global wifi_sleep_policy 2
docker exec appium-adb adb shell svc power stayon usb
docker exec appium-adb adb shell dumpsys deviceidle disable
Bestanden
├── Dockerfile # Image: Node LTS + Java 17 + adb + Appium
├── docker-compose.yml # Service-definities (appium + wa-appium-api)
├── entrypoint.sh # USB/TCP-detectie + settings + reconnect-loop
├── wa-appium-api/
│ ├── Dockerfile # Image: Node LTS + Express + WebDriverIO
│ ├── package.json # Dependencies
│ └── server.js # Express REST API
├── smoke-test.js # Node.js smoke-test (geen dependencies)
├── .env.example # Voorbeeld env-variabelen
├── android-notification-listener/ # Android-app: notificaties → webhook
│ ├── app/src/main/java/.../ # Kotlin broncode
│ │ ├── SettingsActivity.kt
│ │ ├── WhatsAppNotificationListener.kt
│ │ └── WebhookClient.kt
│ ├── build.gradle.kts
│ └── settings.gradle.kts
└── README.md # Dit bestand
Zie ook
- Whatsapp rest-api — primaire WhatsApp API (wwebjs-api,
:3001) - Android Telefoon — Nexus 6P met Termux
- Whatsapp Scripts Migratie — overzicht WhatsApp automatisering