No description
  • JavaScript 69.3%
  • Kotlin 21.2%
  • Shell 6.4%
  • Dockerfile 3.1%
Find a file
Eddy de Vink 7efe8792ab feat: job queue (FIFO concurrency=1) for all Appium/ADB operations
- 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
2026-07-22 22:10:06 +02:00
android-notification-listener feat: media send/receive (intent-based + notification bridge) 2026-07-22 21:47:59 +02:00
wa-appium-api feat: job queue (FIFO concurrency=1) for all Appium/ADB operations 2026-07-22 22:10:06 +02:00
.env.example feat: notification bridge + webhook system 2026-07-22 20:57:57 +02:00
.gitignore Initial commit 2026-07-22 20:40:10 +02:00
docker-compose.yml feat: media send/receive (intent-based + notification bridge) 2026-07-22 21:47:59 +02:00
Dockerfile Initial commit 2026-07-22 20:40:10 +02:00
entrypoint.sh Initial commit 2026-07-22 20:40:10 +02:00
README.md feat: job queue (FIFO concurrency=1) for all Appium/ADB operations 2026-07-22 22:10:06 +02:00
smoke-test.js Initial commit 2026-07-22 20:40:10 +02:00

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:

  1. udev-regel aanmaken: SUBSYSTEM=="usb", ATTR{idVendor}=="18d1", MODE="0660", GROUP="plugdev"
  2. Gebruiker toevoegen aan plugdev-groep
  3. Kabel los/koppelen of chown root:plugdev /dev/bus/usb/XXX/YYY
  4. 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:

  1. Bestand wordt geüpload naar de API-container
  2. adb push naar /sdcard/Pictures/ op de telefoon
  3. adb shell am start met ACTION.SEND intent naar WhatsApp
  4. Appium: contact zoeken en selecteren in het deelscherm
  5. Caption typen (indien opgegeven)
  6. Send-knop klikken
  7. 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:

  1. Webhook binnen met mediaType: "image"
  2. API wacht MEDIA_WAIT_MS ms (default 8s) zodat WhatsApp de download kan voltooien
  3. adb shell ls -t in WhatsApp Images/ — zoekt het nieuwste bestand
  4. adb pull van dat bestand naar de container
  5. Opslaan met een uniek token, beschikbaar via /download/:token
  6. Fanout naar subscribers met mediaUrl + mediaFilename in 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_MS of 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_MS in 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):

  1. adb start-server
  2. Detecteer USB (adb -d) of TCP (adb connect)
  3. Pas instellingen toe (non-root): wifi_sleep_policy=2, svc power stayon usb, deviceidle disable
  4. Start reconnect loop op achtergrond (elke 30s check of device nog zichtbaar is)
  5. 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 drop
  • ensureWhatsApp(d) — start WhatsApp als het niet op de voorgrond staat
  • goHome(d) — navigeer naar WhatsApp home
  • openChat(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: getChats en fetchMessages waren synchroon (data direct in response). Nu geven ze ook 202 + jobId. Poll GET /jobs/:jobId tot done en check result voor 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 devices op 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>:5555 testen 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:

  1. docker logs appium-adb — zie je reconnect-pogingen?
  2. Kabel: probeer een andere. Goedkope kabels geven drops.
  3. USB-poort: direct op moederbord, niet via hub.
  4. Stay awake: zet aan in Developer options op de telefoon.
  5. Settings opnieuw: docker restart appium-adb.
  6. 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

  1. Android app (android-notification-listener/) luistert naar WhatsApp-notificaties
  2. Bij elk bericht stuurt de app een HTTP POST naar de API-container
  3. De API valideert het gedeelde secret en stuurt het bericht door naar subscribers
  4. 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 messages uit de MessagingStyle, 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