notification_monitor.py 5.03 KB
"""
Notification Monitor Daemon
Runs in Termux with root. Polls `dumpsys notification` and POSTs
new notifications to EventServer at http://127.0.0.1:8765.

Usage:
    python notification_monitor.py [--interval 2] [--verbose]
"""

import re
import json
import time
import hashlib
import argparse
import subprocess
import sys
import urllib.request
from dataclasses import dataclass, field
from typing import Optional

EVENT_SERVER = "http://127.0.0.1:8765"
POLL_INTERVAL = 2  # seconds

# Packages to ignore (system noise)
IGNORED_PKGS = {
    "android",
    "com.android.systemui",
    "com.google.android.gms",
    "com.android.phone",
    "com.google.android.permissioncontroller",
}


@dataclass
class NotifRecord:
    pkg: str
    notif_id: str
    key: str
    title: str = ""
    text: str = ""
    ranking_time_ms: int = 0

    def stable_hash(self) -> str:
        """Dedup key: key + title + text (catches updated notifications)."""
        raw = f"{self.key}|{self.title}|{self.text}"
        return hashlib.md5(raw.encode()).hexdigest()


def run_dumpsys() -> str:
    result = subprocess.run(
        ["su", "-c", "dumpsys notification --noredact"],
        capture_output=True, text=True, timeout=10
    )
    return result.stdout


def parse_notifications(output: str) -> list[NotifRecord]:
    records: list[NotifRecord] = []
    current: Optional[NotifRecord] = None

    # Match NotificationRecord header line
    header_re = re.compile(
        r"NotificationRecord\([^:]+:\s*pkg=(\S+)\s+.*?id=(\d+)\s.*?key=([^\s:]+)"
    )
    key_re = re.compile(r"^\s+key=(-?\d+\|[^\s]+)")
    title_re = re.compile(r"android\.title=String \((.+?)\)\s*$")
    text_re = re.compile(r"android\.text=String \((.+?)\)\s*$")
    time_re = re.compile(r"mRankingTimeMs=(\d+)")

    for line in output.splitlines():
        m = header_re.search(line)
        if m:
            if current:
                records.append(current)
            pkg, notif_id, key = m.group(1), m.group(2), m.group(3)
            current = NotifRecord(pkg=pkg, notif_id=notif_id, key=key)
            continue

        if current is None:
            continue

        if m := key_re.match(line):
            current.key = m.group(1)
        elif m := title_re.search(line):
            current.title = m.group(1)
        elif m := text_re.search(line):
            current.text = m.group(1)
        elif m := time_re.search(line):
            current.ranking_time_ms = int(m.group(1))

    if current:
        records.append(current)

    return [r for r in records if r.pkg not in IGNORED_PKGS and r.title]


def post_event(notif: NotifRecord, verbose: bool = False):
    payload = json.dumps({
        "type": "notification",
        "app": notif.pkg,
        "data": {
            "id": notif.notif_id,
            "key": notif.key,
            "title": notif.title,
            "text": notif.text,
            "ts_ms": notif.ranking_time_ms,
        }
    }).encode()
    req = urllib.request.Request(
        f"{EVENT_SERVER}/events",
        data=payload,
        headers={"Content-Type": "application/json"},
        method="POST"
    )
    try:
        with urllib.request.urlopen(req, timeout=3) as r:
            resp = json.loads(r.read().decode())
            if verbose:
                print(f"[+] {notif.pkg}: {notif.title!r} → event #{resp['id']}", flush=True)
    except Exception as e:
        print(f"[!] POST failed: {e}", file=sys.stderr, flush=True)


def wait_for_server(timeout=30):
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            urllib.request.urlopen(f"{EVENT_SERVER}/health", timeout=2)
            return True
        except Exception:
            time.sleep(1)
    return False


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--interval", type=float, default=POLL_INTERVAL)
    parser.add_argument("--verbose", "-v", action="store_true")
    args = parser.parse_args()

    print("[notification_monitor] Waiting for EventServer...", flush=True)
    if not wait_for_server():
        print("[!] EventServer not reachable at", EVENT_SERVER, file=sys.stderr)
        sys.exit(1)
    print(f"[notification_monitor] Connected. Polling every {args.interval}s", flush=True)

    seen: set[str] = set()

    while True:
        try:
            output = run_dumpsys()
            records = parse_notifications(output)

            new_records = [r for r in records if r.stable_hash() not in seen]
            for r in new_records:
                seen.add(r.stable_hash())
                post_event(r, verbose=args.verbose)

            # Bound seen set size (keep last 2000 hashes)
            if len(seen) > 2000:
                seen = set(list(seen)[-1000:])

        except subprocess.TimeoutExpired:
            print("[!] dumpsys timed out", file=sys.stderr, flush=True)
        except KeyboardInterrupt:
            print("\n[notification_monitor] Stopped.", flush=True)
            break
        except Exception as e:
            print(f"[!] Error: {e}", file=sys.stderr, flush=True)

        time.sleep(args.interval)


if __name__ == "__main__":
    main()