Upload Destinations and the Drop Server¶
BirdNET Live can upload finalized sessions to one or more WebDAV
destinations so a project lead can collect field data from many phones
(issue #284). This page describes the protocol the app speaks, the reference
server shipped in tools/webdav_drop_server/, and the security model. The
user-facing side lives in the Settings guide.
Shape of the feature¶
- One-way. The app only ever writes. Nothing is downloaded, nothing is deleted remotely, and deleting a session on the phone leaves the server copy alone.
- Write-only credentials. A contributor credential needs
OPTIONS,MKCOL,PUT, andHEAD. It should not be able toPROPFIND,GET, orDELETE; the app's connection test warns when it can. - Any WebDAV server. A Nextcloud or ownCloud File Drop share already has
exactly these properties, and the app converts a share link
(
https://host/s/TOKEN) to its public WebDAV endpoint. The reference server exists for groups without Nextcloud and adds per-contributor tokens and acollectcommand. - Join link. Contributors receive one link (or its QR code) that carries
the endpoint, user name, secret, project name, and defaults in the URL
fragment, which never reaches a web host. The parser in
lib/features/upload/join_link.dartalso accepts bare WebDAV URLs and Nextcloud share links.
Code map¶
| Piece | Where |
|---|---|
| Destination model, keychain secret store, JSON list | lib/features/upload/upload_destination*.dart, secret_store.dart |
| Join-link parser | lib/features/upload/join_link.dart |
Pre-save probe (same sequence as conformance.py) |
lib/features/upload/webdav_probe.dart |
Credentials: Basic first, Digest (RFC 7616, MD5/SHA-256, qop=auth) on a 401 challenge, one redirect per request |
lib/features/upload/webdav_auth.dart |
| Upload engine, queue store, job model | lib/features/upload/upload_service.dart, upload_queue_store.dart, upload_job.dart |
| Share dialog "Send to" tab | lib/features/history/widgets/share_options_dialog.dart |
| Library chip and status sheet | lib/features/upload/upload_status_widgets.dart |
| Native keychain halves | SecureStoreChannel.kt, SecureStoreChannel.swift, secure_store_channel.cpp |
| Native connectivity halves | ConnectivityChannel.kt, ConnectivityChannel.swift |
What an upload writes¶
<collection>/<deviceCode>/BirdNET_Live_<start>_#<n>[_<name>].zip the Share bundle with the JSON export inside
<collection>/.birdnet-live/<deviceCode>.json one registration per device, from the connection test
<deviceCode> is the first eight hex digits of the install's random id
(PrefKeys.uploadDeviceId, uploadDeviceCode in upload_naming.dart), shown
in the app as "Your upload ID". Paths never carry anything a person typed:
the optional participant identifier (PrefKeys.uploadParticipant) goes into
the registration record and the meta block of each uploaded JSON record,
next to deviceCode.
The ZIP is buildSessionExport with alwaysZip: true and json forced into
the formats, so the lead always gets structured data and exactly one file per
session. A re-upload after an edit writes the same path: servers that allow
it replace the file, a Nextcloud File Drop keeps the older copy under a
numbered name. Servers that refuse MKCOL get the folder folded into the
name (<deviceCode>__BirdNET_Live_….zip, .birdnet-live__<deviceCode>.json). A
412 on PUT is treated as "already there" so a retry after a kill between
PUT and bookkeeping does not fail on an immutable server.
The registration file carries the device id and code, the participant identifier, the app version, and the platform, so the lead can match folders to people. The leading dot keeps the folder out of Nextcloud's default file list.
Queue behavior¶
Jobs persist in uploads.json next to the destination list. A run is
triggered by enqueueing, app start, and return to foreground
(_UploadQueueListener in app.dart). Failed jobs back off 1, 2, 4 … minutes
up to six hours and give up after 30 attempts. Destinations with
"Upload on Wi-Fi only" wait while the device is on cellular; with no network
the run stops early.
On Android the queue also runs with the app closed: after every run the
service reports what is pending through UploadWorkScheduler
(com.birdnet/upload_work), and UploadWorker.kt enqueues one unique
WorkManager job with a CONNECTED or UNMETERED constraint. When it fires,
the worker starts a headless Flutter engine on uploadQueueBackgroundMain
(upload_background.dart), which builds the same providers as the app, runs
the queue once, and reports the remaining count so the worker retries with
backoff. UploadQueueStore.tryLock keeps the app and the worker from running
the queue at the same time. On iOS there is no headless run: the queue runs
whenever the app is open or returns to the foreground, builds and stages each
export there, and hands the PUT to a background URLSession
(UploadTransferChannel.swift, BackgroundTransfer in
upload_background.dart), which finishes it while the app is suspended or
gone. The job stays "uploading" until the outcome comes back, over the channel
while the app lives or through recover at the start of the next run after a
relaunch. App Transport Security binds that session, so a plain-http
destination keeps the in-app PUT and uploads only while the app is open.
Reference server¶
tools/webdav_drop_server/drop_server.py is a standard-library HTTP server
with init, create-project, invite, revoke, list, serve, and
collect commands; conformance.py probes any endpoint the way the app
does. See the README
there for deployment behind Caddy or nginx.
Security properties of the reference server:
- Tokens are stored as salted SHA-256 hashes and compared in constant time; each is bound to one project and lands under its own folder regardless of the path the client sends.
- Path segments are validated;
.., backslashes, and control characters are refused. PUTmay replace a contributor's own file (204);DELETE,MOVE,COPY, andPROPPATCHanswer403for everyone.- A per-token request-rate limit and a per-file size limit are configurable.
- The admin token can
PROPFINDandGET; contributors cannot.