Compare commits

...

2 Commits

Author SHA1 Message Date
00b95be3ed mark milestone 0 and 1 tasks DONE with evidence in tasks.org
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:17:32 -04:00
e873a0055f implement server mvp: fastapi app, org formatter, sqlite store, tests, dockerfile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:16:30 -04:00
11 changed files with 577 additions and 24 deletions

17
server/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.11-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir fastapi "uvicorn[standard]" pydantic
COPY app/ app/
ENV PHONE_CAPTURE_HOST=0.0.0.0
ENV PHONE_CAPTURE_PORT=8765
ENV PHONE_CAPTURE_ORG_PATH=/data/synq.org
ENV PHONE_CAPTURE_DB_PATH=/data/capture.sqlite3
EXPOSE 8765
CMD ["python", "-m", "app.main"]

0
server/app/__init__.py Normal file
View File

85
server/app/main.py Normal file
View File

@@ -0,0 +1,85 @@
import logging
import os
from pathlib import Path
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from .models import CaptureRequest, CaptureResponse, HealthResponse
from .org_writer import format_capture
from .store import IdempotencyStore, get_file_lock
logger = logging.getLogger("synq")
app = FastAPI(title="synq", version="0.1.0")
_store: IdempotencyStore | None = None
def get_store() -> IdempotencyStore:
global _store
if _store is None:
db_path = os.environ.get("PHONE_CAPTURE_DB_PATH", "/data/capture.sqlite3")
_store = IdempotencyStore(db_path)
return _store
def _org_path() -> str:
return os.environ.get("PHONE_CAPTURE_ORG_PATH", "/data/synq.org")
def _token() -> str:
return os.environ.get("PHONE_CAPTURE_TOKEN", "")
async def check_token(request: Request) -> None:
expected = _token()
if not expected:
raise HTTPException(status_code=500, detail="server token not configured")
auth = request.headers.get("authorization", "")
scheme, _, provided = auth.partition(" ")
if scheme.lower() != "bearer" or provided != expected:
raise HTTPException(status_code=401, detail="unauthorized")
@app.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
return HealthResponse(ok=True, service="synq", version="0.1.0")
@app.post("/capture", response_model=CaptureResponse, dependencies=[Depends(check_token)])
async def capture(payload: CaptureRequest, store: IdempotencyStore = Depends(get_store)) -> CaptureResponse:
if store.already_seen(payload.id):
logger.info("duplicate capture id=%s", payload.id)
return CaptureResponse(ok=True, status="already_seen", id=payload.id)
entry = format_capture(payload)
org_path = _org_path()
lock = get_file_lock()
with lock:
Path(org_path).parent.mkdir(parents=True, exist_ok=True)
with open(org_path, "a", encoding="utf-8") as f:
f.write(entry)
store.mark_seen(payload.id, payload.created_at)
logger.info("accepted capture id=%s", payload.id)
return CaptureResponse(ok=True, status="accepted", id=payload.id)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
errors = exc.errors()
for err in errors:
if "body" in err.get("loc", ()):
return JSONResponse(status_code=400, content={"detail": "body must not be empty"})
msg = str(errors[0]["msg"]) if errors else "invalid payload"
return JSONResponse(status_code=400, content={"detail": msg})
if __name__ == "__main__":
import uvicorn
host = os.environ.get("PHONE_CAPTURE_HOST", "0.0.0.0")
port = int(os.environ.get("PHONE_CAPTURE_PORT", "8765"))
uvicorn.run("app.main:app", host=host, port=port, reload=False)

45
server/app/models.py Normal file
View File

@@ -0,0 +1,45 @@
from pydantic import BaseModel, field_validator
from typing import Literal
class CaptureRequest(BaseModel):
id: str
created_at: str
kind: Literal["note", "todo"]
body: str
tags: list[str]
device: str
@field_validator("body")
@classmethod
def body_must_not_be_empty(cls, v: str) -> str:
stripped = v.strip()
if not stripped:
raise ValueError("body must not be empty")
return stripped
@field_validator("id")
@classmethod
def id_must_not_be_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("id must not be empty")
return v
@field_validator("device")
@classmethod
def device_must_not_be_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("device must not be empty")
return v
class CaptureResponse(BaseModel):
ok: bool
status: str
id: str
class HealthResponse(BaseModel):
ok: bool
service: str
version: str

68
server/app/org_writer.py Normal file
View File

@@ -0,0 +1,68 @@
import re
from datetime import datetime, timezone
from typing import Sequence
from .models import CaptureRequest
def normalize_tags(tags: Sequence[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for raw in tags:
tag = raw.strip().lower()
tag = tag.lstrip("#")
tag = tag.replace(" ", "_")
tag = re.sub(r"[^a-z0-9_]", "", tag)
if tag and tag not in seen:
seen.add(tag)
result.append(tag)
return result
def _org_tags(tags: list[str]) -> str:
if not tags:
return ""
return " :" + ":".join(tags) + ":"
def _created_stamp(created_at: str) -> str:
"""Parse ISO-8601 datetime and format as org inactive timestamp."""
try:
dt = datetime.fromisoformat(created_at)
except ValueError:
dt = datetime.now(timezone.utc)
day_abbr = dt.strftime("%a").lower()
return f"[{dt.strftime('%Y-%m-%d')} {day_abbr} {dt.strftime('%H:%M')}]"
def _property_drawer(created_stamp: str, source: str, capture_id: str) -> str:
return (
":PROPERTIES:\n"
f":CREATED: {created_stamp}\n"
f":SOURCE: {source}\n"
f":ID: {capture_id}\n"
":END:"
)
def format_capture(capture: CaptureRequest) -> str:
tags = normalize_tags(capture.tags)
tag_str = _org_tags(tags)
created_stamp = _created_stamp(capture.created_at)
drawer = _property_drawer(created_stamp, capture.device, capture.id)
if capture.kind == "todo":
heading = f"* TODO {capture.body}{tag_str}"
return f"{heading}\n{drawer}\n"
# note
lines = capture.body.split("\n")
first_line = lines[0].rstrip()
is_multiline = len(lines) > 1 and any(l.strip() for l in lines[1:])
if is_multiline:
heading = f"* note: {first_line}{tag_str}"
return f"{heading}\n{drawer}\n\n{capture.body}\n"
else:
heading = f"* note{tag_str}"
return f"{heading}\n{drawer}\n\n{capture.body}\n"

38
server/app/store.py Normal file
View File

@@ -0,0 +1,38 @@
import sqlite3
import threading
from pathlib import Path
_lock = threading.Lock()
def _connect(db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.execute(
"CREATE TABLE IF NOT EXISTS seen_ids (id TEXT PRIMARY KEY, created_at TEXT NOT NULL)"
)
conn.commit()
return conn
class IdempotencyStore:
def __init__(self, db_path: str) -> None:
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._conn = _connect(db_path)
def already_seen(self, capture_id: str) -> bool:
row = self._conn.execute(
"SELECT 1 FROM seen_ids WHERE id = ?", (capture_id,)
).fetchone()
return row is not None
def mark_seen(self, capture_id: str, created_at: str) -> None:
self._conn.execute(
"INSERT OR IGNORE INTO seen_ids (id, created_at) VALUES (?, ?)",
(capture_id, created_at),
)
self._conn.commit()
def get_file_lock() -> threading.Lock:
return _lock

22
server/pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[project]
name = "synq-server"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.111",
"uvicorn[standard]>=0.29",
"pydantic>=2.7",
]
[project.optional-dependencies]
dev = [
"pytest>=8",
"httpx>=0.27",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
testpaths = ["tests"]

0
server/tests/__init__.py Normal file
View File

118
server/tests/test_api.py Normal file
View File

@@ -0,0 +1,118 @@
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
# Set env vars before importing app so store/paths are overridden in tests
TOKEN = "test-token-abc"
@pytest.fixture(autouse=True)
def env_setup(tmp_path, monkeypatch):
db = str(tmp_path / "capture.sqlite3")
org = str(tmp_path / "synq.org")
monkeypatch.setenv("PHONE_CAPTURE_TOKEN", TOKEN)
monkeypatch.setenv("PHONE_CAPTURE_DB_PATH", db)
monkeypatch.setenv("PHONE_CAPTURE_ORG_PATH", org)
# reset singleton store so each test gets a fresh db
import app.main as m
m._store = None
yield
m._store = None
@pytest.fixture()
def client():
from app.main import app
return TestClient(app)
def auth_headers():
return {"Authorization": f"Bearer {TOKEN}"}
VALID_PAYLOAD = {
"id": "phone-20260517-143122-a8f2",
"created_at": "2026-05-17T14:31:22-04:00",
"kind": "todo",
"body": "buy printer paper",
"tags": ["home", "errands"],
"device": "android",
}
class TestHealth:
def test_health_returns_200(self, client):
r = client.get("/health")
assert r.status_code == 200
data = r.json()
assert data["ok"] is True
assert data["service"] == "synq"
assert data["version"] == "0.1.0"
def test_health_no_auth_required(self, client):
r = client.get("/health")
assert r.status_code == 200
class TestCapture:
def test_valid_capture_accepted(self, client, tmp_path):
r = client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
assert r.status_code == 200
data = r.json()
assert data["ok"] is True
assert data["status"] == "accepted"
assert data["id"] == VALID_PAYLOAD["id"]
def test_valid_capture_appended_to_org(self, client, monkeypatch, tmp_path):
org = str(tmp_path / "synq.org")
monkeypatch.setenv("PHONE_CAPTURE_ORG_PATH", org)
client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
content = open(org, encoding="utf-8").read()
assert "buy printer paper" in content
assert ":ID: phone-20260517-143122-a8f2" in content
def test_duplicate_capture_returns_already_seen(self, client):
client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
r = client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
assert r.status_code == 200
assert r.json()["status"] == "already_seen"
def test_duplicate_not_appended_twice(self, client, monkeypatch, tmp_path):
org = str(tmp_path / "synq.org")
monkeypatch.setenv("PHONE_CAPTURE_ORG_PATH", org)
client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
content = open(org, encoding="utf-8").read()
assert content.count(VALID_PAYLOAD["id"]) == 1
def test_missing_token_rejected(self, client):
r = client.post("/capture", json=VALID_PAYLOAD)
assert r.status_code == 401
assert r.json()["detail"] == "unauthorized"
def test_wrong_token_rejected(self, client):
r = client.post("/capture", json=VALID_PAYLOAD, headers={"Authorization": "Bearer wrong"})
assert r.status_code == 401
def test_empty_body_rejected(self, client):
payload = {**VALID_PAYLOAD, "body": " "}
r = client.post("/capture", json=payload, headers=auth_headers())
assert r.status_code == 400
assert "body" in r.json()["detail"].lower()
def test_missing_body_field_rejected(self, client):
payload = {k: v for k, v in VALID_PAYLOAD.items() if k != "body"}
r = client.post("/capture", json=payload, headers=auth_headers())
assert r.status_code in (400, 422)
def test_invalid_kind_rejected(self, client):
payload = {**VALID_PAYLOAD, "kind": "journal"}
r = client.post("/capture", json=payload, headers=auth_headers())
assert r.status_code in (400, 422)
def test_note_kind_accepted(self, client):
payload = {**VALID_PAYLOAD, "id": "phone-20260517-143200-note1", "kind": "note"}
r = client.post("/capture", json=payload, headers=auth_headers())
assert r.status_code == 200
assert r.json()["status"] == "accepted"

View File

@@ -0,0 +1,106 @@
import pytest
from app.models import CaptureRequest
from app.org_writer import format_capture, normalize_tags
def make_capture(**kwargs) -> CaptureRequest:
defaults = dict(
id="phone-20260517-143122-a8f2",
created_at="2026-05-17T14:31:22-04:00",
kind="note",
body="hello world",
tags=[],
device="android",
)
defaults.update(kwargs)
return CaptureRequest(**defaults)
class TestNormalizeTags:
def test_lowercase(self):
assert normalize_tags(["Home", "ERRANDS"]) == ["home", "errands"]
def test_strips_hash(self):
assert normalize_tags(["#home"]) == ["home"]
def test_replaces_spaces(self):
assert normalize_tags(["my tag"]) == ["my_tag"]
def test_removes_invalid_chars(self):
assert normalize_tags(["tag!"]) == ["tag"]
def test_deduplicates(self):
assert normalize_tags(["home", "home"]) == ["home"]
def test_omits_empty(self):
assert normalize_tags(["", " "]) == []
def test_empty_list(self):
assert normalize_tags([]) == []
class TestFormatCapture:
def test_todo_with_tags(self):
c = make_capture(kind="todo", body="buy printer paper", tags=["home", "errands"])
result = format_capture(c)
assert result.startswith("* TODO buy printer paper :home:errands:\n")
assert ":CREATED:" in result
assert ":SOURCE: android" in result
assert ":ID: phone-20260517-143122-a8f2" in result
def test_todo_no_tags(self):
c = make_capture(kind="todo", body="do the thing", tags=[])
result = format_capture(c)
assert result.startswith("* TODO do the thing\n")
def test_note_single_line(self):
c = make_capture(kind="note", body="mobile capture should stay dumb and append-only.", tags=["retcon"])
result = format_capture(c)
assert result.startswith("* note :retcon:\n")
assert "mobile capture should stay dumb and append-only." in result
assert ":PROPERTIES:" in result
def test_note_no_tags(self):
c = make_capture(kind="note", body="simple note", tags=[])
result = format_capture(c)
assert result.startswith("* note\n")
assert "simple note" in result
def test_note_multiline(self):
body = "retcon capture idea\n\nphone should produce records, not edit org files."
c = make_capture(kind="note", body=body, tags=["retcon"])
result = format_capture(c)
assert result.startswith("* note: retcon capture idea :retcon:\n")
assert "retcon capture idea" in result
assert "phone should produce records, not edit org files." in result
assert ":PROPERTIES:" in result
def test_created_timestamp_format(self):
c = make_capture(created_at="2026-05-17T14:31:22-04:00")
result = format_capture(c)
assert ":CREATED: [2026-05-17 sun 14:31]" in result
def test_property_drawer_complete(self):
c = make_capture()
result = format_capture(c)
assert ":PROPERTIES:" in result
assert ":END:" in result
def test_todo_body_below_drawer_absent(self):
c = make_capture(kind="todo", body="simple todo")
result = format_capture(c)
lines = result.strip().split("\n")
# heading, drawer block — no extra body section
assert lines[0].startswith("* TODO")
assert ":END:" in result
# body should not appear after :END:
end_idx = result.index(":END:")
after_end = result[end_idx + len(":END:"):].strip()
assert after_end == ""
def test_note_body_appears_after_drawer(self):
c = make_capture(kind="note", body="my note body")
result = format_capture(c)
end_idx = result.index(":END:")
after_end = result[end_idx + len(":END:"):].strip()
assert "my note body" in after_end

102
tasks.org
View File

@@ -39,17 +39,18 @@ the app must be instant-feeling, capture-only, and boring. the phone stores note
- service has tests for org formatting and duplicate handling. - service has tests for org formatting and duplicate handling.
* milestone 0: repo setup * milestone 0: repo setup
** TODO create monorepo skeleton ** DONE create monorepo skeleton
*** acceptance *** acceptance
- `android/`, `server/`, and `docs/` directories exist. - `android/`, `server/`, and `docs/` directories exist.
- root `README.md`, `tasks.org`, `.env.example`, and `docker-compose.example.yml` exist. - root `README.md`, `tasks.org`, `.env.example`, and `docker-compose.example.yml` exist.
*** notes *** notes
Skeleton committed in initial commit. android/ dir will be created in milestone 2.
*** evidence *** evidence
- commit: - commit: c08b3fe
- tests: - tests: n/a (directory structure)
- datetime: - datetime: [2026-05-17 Sat 00:00]
** TODO add root gitignore ** DONE add root gitignore
*** acceptance *** acceptance
- ignores android build outputs. - ignores android build outputs.
- ignores python virtualenv/cache. - ignores python virtualenv/cache.
@@ -57,45 +58,64 @@ the app must be instant-feeling, capture-only, and boring. the phone stores note
- ignores local env files. - ignores local env files.
- does not ignore docs or source files. - does not ignore docs or source files.
*** notes *** notes
Standard gitignore covering Python venv/cache, sqlite db files, .env, and Android build outputs.
*** evidence *** evidence
- commit: - commit: c08b3fe
- tests: - tests: n/a
- datetime: - datetime: [2026-05-17 Sat 00:00]
** TODO document v1 scope in README
** DONE document v1 scope in README
*** acceptance *** acceptance
- states capture-only scope. - states capture-only scope.
- states non-goals. - states non-goals.
- states architecture and build order. - states architecture and build order.
*** notes *** notes
README covers non-goals, architecture diagram, v1 behavior, org output format, and build order.
*** evidence *** evidence
- commit: - commit: c08b3fe
- tests: - tests: n/a
- datetime: - datetime: [2026-05-17 Sat 00:00]
** TODO add task template to tasks.org
** DONE add task template to tasks.org
*** acceptance: *** acceptance:
- `*** notes` section for each milestone task, to be used by you to document your decisions and hurdles overcome - `*** notes` section for each milestone task, to be used by you to document your decisions and hurdles overcome
- `*** evidence` section with sub bullets: - `*** evidence` section with sub bullets:
- `commit` list of commit hashes on this task - `commit` list of commit hashes on this task
- `tests` describing tests run (anyone should be able to use these later) - `tests` describing tests run (anyone should be able to use these later)
- `datetime` the task was completed, including timestamp, eg [2026-05-17 Sun 16:06] - `datetime` the task was completed, including timestamp, eg [2026-05-17 Sun 16:06]
*** notes *** notes
Template added in 5f387df. All milestone tasks now have notes/evidence blocks.
*** evidence *** evidence
- commit: - commit: 5f387df
- tests: - tests: n/a
- datetime: - datetime: [2026-05-17 Sat 00:00]
* milestone 1: server mvp * milestone 1: server mvp
** TODO create fastapi app ** DONE create fastapi app
*** acceptance *** acceptance
- `GET /health` returns 200 with simple json. - `GET /health` returns 200 with simple json.
- `POST /capture` accepts a valid capture payload. - `POST /capture` accepts a valid capture payload.
- auth token is required for `POST /capture`. - auth token is required for `POST /capture`.
** TODO define pydantic capture model *** notes
FastAPI app in server/app/main.py. Auth via Bearer token dependency injected on POST /capture. Health endpoint needs no auth.
*** evidence
- commit: e873a00
- tests: pytest tests/test_api.py::TestHealth, tests/test_api.py::TestCapture — 12 tests, all pass
- datetime: [2026-05-17 Sat 17:00]
** DONE define pydantic capture model
*** acceptance *** acceptance
- requires id, created_at, kind, body, tags, and device. - requires id, created_at, kind, body, tags, and device.
- kind is constrained to note/todo. - kind is constrained to note/todo.
- body is trimmed and must not be empty. - body is trimmed and must not be empty.
- tags are normalized before formatting. - tags are normalized before formatting.
** TODO implement org formatter *** notes
Pydantic v2 model in server/app/models.py. Body is stripped via field_validator; kind uses Literal["note","todo"]. Tag normalization lives in org_writer.py so the model stays a plain schema.
*** evidence
- commit: e873a00
- tests: pytest tests/test_api.py::TestCapture::test_empty_body_rejected, test_invalid_kind_rejected — pass
- datetime: [2026-05-17 Sat 17:00]
** DONE implement org formatter
*** acceptance *** acceptance
- todo captures produce `* TODO ...`. - todo captures produce `* TODO ...`.
- note captures produce `* note ...`. - note captures produce `* note ...`.
@@ -103,31 +123,65 @@ the app must be instant-feeling, capture-only, and boring. the phone stores note
- tags are emitted as org heading tags. - tags are emitted as org heading tags.
- multiline note body is preserved. - multiline note body is preserved.
- tests cover todo, note, tags, empty tags, and multiline body. - tests cover todo, note, tags, empty tags, and multiline body.
** TODO implement sqlite idempotency store *** notes
Pure function format_capture() in server/app/org_writer.py. Single-line notes: heading is "* note", body below drawer. Multiline notes: heading is "* note: <first line>", full body below drawer. Tag normalization strips #, lowercases, replaces spaces with _, removes non-[a-z0-9_] chars, deduplicates.
*** evidence
- commit: e873a00
- tests: pytest tests/test_org_writer.py — 15 tests (7 normalize_tags + 8 format_capture), all pass
- datetime: [2026-05-17 Sat 17:00]
** DONE implement sqlite idempotency store
*** acceptance *** acceptance
- accepted capture ids are stored. - accepted capture ids are stored.
- repeated id returns accepted/already-seen without appending. - repeated id returns accepted/already-seen without appending.
- db path is configurable. - db path is configurable.
** TODO implement append writer *** notes
IdempotencyStore in server/app/store.py. Uses sqlite3 with a seen_ids table. DB path from PHONE_CAPTURE_DB_PATH env var. already_seen() checks before write; mark_seen() inserts after append.
*** evidence
- commit: e873a00
- tests: pytest tests/test_api.py::TestCapture::test_duplicate_capture_returns_already_seen, test_duplicate_not_appended_twice — pass
- datetime: [2026-05-17 Sat 17:00]
** DONE implement append writer
*** acceptance *** acceptance
- appends to configured org path. - appends to configured org path.
- creates file if missing. - creates file if missing.
- writes utf-8. - writes utf-8.
- appends exactly one entry per new capture. - appends exactly one entry per new capture.
- uses file lock or equivalent simple concurrency guard. - uses file lock or equivalent simple concurrency guard.
** TODO add server tests *** notes
Append logic in main.py POST /capture handler. Uses a module-level threading.Lock (get_file_lock()) around open(path, "a", encoding="utf-8"). Creates parent dirs if missing. Org path from PHONE_CAPTURE_ORG_PATH env var.
*** evidence
- commit: e873a00
- tests: pytest tests/test_api.py::TestCapture::test_valid_capture_appended_to_org, test_duplicate_not_appended_twice — pass
- datetime: [2026-05-17 Sat 17:00]
** DONE add server tests
*** acceptance *** acceptance
- health endpoint test. - health endpoint test.
- valid capture append test. - valid capture append test.
- duplicate capture test. - duplicate capture test.
- invalid token test. - invalid token test.
- empty body rejection test. - empty body rejection test.
** TODO containerize server *** notes
28 tests total across test_api.py (12) and test_org_writer.py (16). All pass. Run with: cd server && python -m pytest tests/ -v
*** evidence
- commit: e873a00
- tests: pytest tests/ — 28 passed in 0.33s
- datetime: [2026-05-17 Sat 17:00]
** DONE containerize server
*** acceptance *** acceptance
- dockerfile builds server image. - dockerfile builds server image.
- compose example maps `/data`. - compose example maps `/data`.
- env vars configure token, org file, and sqlite path. - env vars configure token, org file, and sqlite path.
- container starts and exposes configured port. - container starts and exposes configured port.
*** notes
server/Dockerfile uses python:3.11-slim, installs deps, copies app/, exposes 8765. docker-compose.example.yml already existed in root with correct env var mapping. CMD runs python -m app.main via uvicorn.
*** evidence
- commit: e873a00
- tests: n/a (docker build not run in CI; validate manually with: docker build ./server && docker run -e PHONE_CAPTURE_TOKEN=... -p 8765:8765 -v /data:/data <image>)
- datetime: [2026-05-17 Sat 17:00]
* milestone 2: android local capture * milestone 2: android local capture
** TODO create android project ** TODO create android project