Secretary Contacts (Android)
Kontaktverwaltung als Android-App für den secretary-server (CardDAV) mit Offline-Cache, lokalen Kontakten und dem Zusammenführen von Messenger-Kontakten (WhatsApp, Telegram, Signal, Element, Skype, …) mit CardDAV-Kontakten.
What it does
- Three address books in one list: local contacts that never leave the device, Messenger contacts (read-only), and the CardDAV collection(s) from your secretary-server
- CardDAV sync with an offline cache and a write-behind queue for edits made without a connection
- Messenger merge: contacts from WhatsApp, Telegram, Signal, Element, Messenger and Skype are matched against CardDAV contacts (phone/e-mail as a strong, name as a weak signal) and merged - phones and e-mails deduplicated, empty fields filled in - or a new CardDAV contact is created
- Merged messenger contacts disappear from the open list and are remembered, so the merge happens once
- Sync into the Android system contacts provider
- Search across all address books at once
Changelog
Every released version: Apps/contacts/changelog
Screenshots

Verzeichnisstruktur
secretary-contacts-android/
├── build.gradle.kts # Root-Build
├── settings.gradle.kts
├── gradle.properties
├── CONTEXT.md
├── doc/ # (Architektur-Doku: Wiki Apps/contacts/architecture)
├── gradle/wrapper/
└── app/
├── build.gradle.kts
├── proguard-rules.pro
└── src/main/
├── AndroidManifest.xml
├── res/values/
│ ├── strings.xml
│ └── themes.xml
├── java/org/beowulf/secretarycontacts/
│ ├── SecretaryContactsApp.kt # Application
│ ├── MainActivity.kt # Single-Activity
│ ├── data/
│ │ ├── Models.kt # Contact, ContactValue, AddressBook
│ │ ├── VCard.kt # RFC 2426 Parse/Serialize
│ │ ├── CardDavClient.kt # PROPFIND/REPORT/GET/PUT/DELETE/MKCOL
│ │ ├── AccountResolver.kt # zentrales Konto (secretary-account)
│ │ ├── ContactsRepository.kt # Prefs: Config, letztes Buch, gemergte Messenger
│ │ ├── ContactStore.kt # JSON-Cache + Offline-Pending-Queue
│ │ ├── MessengerSource.kt # Kontakte-Provider (Messenger-RawContacts)
│ │ └── ContactMerge.kt # Vorschläge + Union-Merge
│ ├── system/
│ │ ├── ContactSystemSyncAdapter.kt # System-Provider-Sync (com.android.contacts)
│ │ ├── ContactSystemSyncService.kt # Sync-Service (IBinder)
│ │ ├── ContactSystemScheduler.kt # periodisch + manueller Sync
│ │ └── PhoneNumbers.kt # TEL → Phone.NUMBER + NORMALIZED_NUMBER
│ ├── vm/
│ │ └── ContactsViewModel.kt # App-State, Sync, Merge
│ └── ui/
│ ├── Theme.kt # Material 3
│ ├── LoginScreen.kt
│ ├── ContactListScreen.kt # Liste, Suche, Adressbuch-Chips, Merge-Dialog
│ ├── ContactEditScreen.kt
│ ├── SettingsScreen.kt
│ └── navigation/NavGraph.kt
└── src/test/java/org/beowulf/secretarycontacts/data/
├── VCardTest.kt
├── CardDavClientTest.kt
├── ContactStoreTest.kt
└── ContactMergeTest.kt
Befehle
Voraussetzungen: JDK 21, Android-SDK (Platform 37, Build-Tools 34)
und Gradle 9.x. Im Repository liegt bewusst kein Gradle-Wrapper --
entweder Android Studio nutzen (bringt sein Gradle mit) oder Gradle 9 selbst
installieren; die Beispiele unten rufen deshalb gradle direkt.
export ANDROID_HOME="$HOME/Android/Sdk" # bzw. sdk.dir=... in local.properties
gradle :contacts:assembleDebug # Debug-APK
gradle :contacts:testDebugUnitTest # Unit-Tests
gradle :contacts:lintDebug # Lint
Konzepte
- Adressbücher: drei Quellen in einer Liste – "Lokal" (nur auf dem Gerät), "Messenger" (nur auf dem Gerät, read-only) und die CardDAV-Collection(s) des secretary-servers.
- Offline: Kontakte liegen als JSON-Cache (
app/filesDir/contacts/) vor; Änderungen gehen bei fehlender Verbindung in eine Write-Behind-Queue (PUT/DELETE/CreateBook) und werden beim nächsten erfolgreichenloadAddressBooks()abgespielt. - Messenger-Zusammenführen: das Pseudoadressbuch "Messenger" liest die Kontakte der installierten Messenger-Apps über den System-Kontakte-Provider (ACCOUNT_TYPEs von WhatsApp, Telegram, Signal, Element, Messenger, Skype). Pro Messenger-Kontakt schlägt die App passende CardDAV-Kontakte vor (Telefon/E-Mail – stark, Name – schwach) und führt sie per Union zusammen (Telefone/E-Mails dedupliziert, leere Felder aufgefüllt) oder legt einen neuen CardDAV-Kontakt an. Zusammengeführte Messenger-Kontakte werden in den Prefs gemerkt und aus der offenen Liste entfernt.
- Zentrales Konto: Zugangsdaten kommen aus
secretary-account(ContentProvider, Signature-PermissionREAD_ACCOUNT), mit lokalem SharedPreferences-Cache und Legacy-Fallback für die eigene Anmeldung.
Berechtigungen
INTERNET– CardDAV-Zugrifforg.beowulf.secretaryaccount.permission.READ_ACCOUNT(signature) – zentrales KontoREAD_CONTACTS(Laufzeit) – Messenger-Kontakte aus dem Kontakte-ProviderWRITE_CONTACTS(Laufzeit, seit API 23) – Schreiben in den System-Kontakte-Provider. Die Manifest-Deklaration allein gewährt nichts: ohne die Freigabe schlägt jeder Schreibvorgang im Provider mit einerSecurityExceptionfehl und der Spiegel bleibt leer. Gefragt wird inMainActivity(einmal pro Installation,MainActivity.kt,permission_state/asked_write_contacts) und noch einmal in den Einstellungen, sobald der Zustand dort sichtbar wird;ContactSystemScheduler.rearm()und der Sync-Adapter laufen kopflos und können die Berechtigung nicht selbst einholen.GET_ACCOUNTS,AUTHENTICATE_ACCOUNTS,MANAGE_ACCOUNTS– Sync über das geteilte secretary-Konto- READ_SYNC_SETTINGS,
WRITE_SYNC_SETTINGS– SyncAdapter-Freigabe/periodisch
Systemkontakte (Sync in den System-Provider)
- SyncAdapter:
system/ContactSystemSyncAdapter.kt+ContactSystemSyncServiceregistriert fürcom.android.providers.contacts(res/xml/syncadapter_contacts.xml,contentAuthority="com.android.contacts",supportsUploading="true"). - Konten: ein Android-Konto pro CardDAV-Adressbuch (DAVx5-Muster,
AddressBookAccounts.kt, Account-Typorg.beowulf.secretary.addressbook, in den Android-Einstellungen viauserVisible="false"versteckt). Dadurch bietet der Dialer/die Kontakte-App jede secretary-Collection beim Anlegen eines neuen Kontakts als Ziel an – der frühere einseitige Gesamtspiegel unter dem Hauptkontoorg.beowulf.secretary(supportsUploading="false") wurde nie als Ziel angeboten. Der Hauptkonto-Typ ist kein Kontakt-Ziel mehr; bei der Reconciliation werden dessen verwaiste RawContacts entfernt. - Richtungen: (1) Mirror Cache → System-Provider (
RawContacts+DataviaContentProviderOperation, Datumsschema:SOURCE_ID/SYNC1/SYNC2= UID/ETag, DIRTY=0); gelöschte UIDs werden aus dem Provider entfernt. (2) Upload Provider → CardDAV: steuert der Nutzer im Dialer/Kontakte-App einen raw contact unter einem Adressbuch-Konto bei (DIRTY, ggf. DELETED), wird er als vCard per PUT an den secretary-Server übertragen, in den Offline-Cache übernommen und als clean markiert; Server-DELETEs entfernen den raw contact. Messenger-Kontakte werden übersprungen (stammen aus dem System-Provider). - Trigger: nach Login, nach Adressbuch-/Kontakt-Load, Kaltstart mit Cache,
Adressbuch-Anlegen/-Umbenennen/-Kopieren/-Löschen, periodisch (alle 6 h) und
manuell in den Einstellungen. Jeder Lauf holt das Buch vorher selbst vom
Server (
refreshFromServer,OfflineSyncEngine; die Collection wird per PROPFIND nachgeschlagen, falls der Cache sie nicht kennt) – der Offline-Cache enthält nur Bücher, die in der App geöffnet wurden, ein kopfloser Lauf hätte sonst nichts zu spiegeln. Wichtig: nur ein erfolgreicher Abruf darf ein leeres Buch melden; der Mirror löscht jede RawContact, deren uid im Cache fehlt, und würde ein Offline-Lauf sonst das ganze Buch aus dem Provider löschen (WhatsApp/Wähler zeigten danach nur Rufnummern). - Telefonnummern:
system/PhoneNumbersnormalisiert die vCard-TEL-Werte vor dem Schreiben in den Provider und liefert zusätzlich zuPhone.NUMBERdie E.164-Form alsPhone.NORMALIZED_NUMBER. Das ist der Schlüssel, über denContactsContract.PhoneLookup– und damit WhatsApp – den Namen findet; ein ungefilterterTEL(Tel:,;ext=,00-Präfix) erzeugt dort keinen brauchbaren Eintrag. - Fotos:
dataValuesschreibtphotoB64alsPhoto.PHOTO. Der Wert muss unkompiliertes Base64 sein –PhotoCodec.encodenutzt dafürBase64.getEncoder(), und der Upload-Pfad liest Bilder aus dem Provider mitBase64.NO_WRAP.Base64.DEFAULTist hier ein stiller Datenverlust: es bricht alle 76 Zeichen um,VCard.foldfaltet das ein zweites Mal mitten durch die Umbrüche, und jeder RFC-konforme Parser liest nur die erste Zeile (76 Zeichen = JPEG-Kopf).VCard.foldsetzt inzwischen jede Teilstufe selbst mit dem Faltzeichen, undVCard.unfoldheilt bereits vorhandene, doppelt gefaltete Werte wieder auf (nurPHOTO;ENCODING=b, nur Zeilen ohne:) – siehe Status 67. - Dadurch sind die secretary-Kontakte für alle Apps (WhatsApp, Dialer, …)
über
ContactsContract.PhoneLookupsichtbar – ersetzt DAVx5. (Ein separater Provider mit eigener Authority würde von WhatsApp nicht konsultiert; deshalb schreibt der SyncAdapter direkt in den System-Provider.)
Server-Referenz
secretary-server(CardDAV, Rust):~/Projects/secretary-server/(/addressbooks/, PROPFIND Depth 1 mit getetag, REPORT addressbook-query)secretary-account-android:~/Projects/secretary-account-android/
Attachments:
- contacts-list.png [download] added by beowulf on 2026-10-06 16:03:27. [details]
- contacts-detail.png [download] added by beowulf on 2026-10-06 16:03:27. [details]