replica(telefon): architecture, tokens (20 pairs AA), components

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 16:21:50 +02:00
co-authored by Claude Opus 5.5
parent 43030575c4
commit 0f709aaff8
5 changed files with 180 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# Architecture: Telefon (Android, ohne Google)
Same conventions as the other apps. **No network permission.** The app owns almost no data: it reads the system call log and contacts.
## Stack
| Schicht | Wahl | warum |
| --- | --- | --- |
| UI | Kotlin + Compose, M3 Expressive, dynamic colour | same as the others |
| Telefonie | `RoleManager.ROLE_DIALER` + own `InCallService` (BIND_INCALL_SERVICE, IN_CALL_SERVICE_UI) | the only official way to show the call UI and control calls |
| Anrufen | `TelecomManager.placeCall` | uses the default SIM/phone account, works for emergency numbers through the system |
| Daten | `CallLog.Calls` and `ContactsContract` via ContentResolver, observed with ContentObserver → Flow | no copy of personal data in our database |
| Eigene Daten | DataStore (quick replies, theme) | – |
| Audio | `InCallService.setMuted / setAudioRoute`, `CallEndpoint` on API 34+ | speaker, earpiece, Bluetooth |
| Näherungssensor | `PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK` while the earpiece is active | screen off at the ear |
## Permissions
READ_CALL_LOG, WRITE_CALL_LOG (delete entries), READ_CONTACTS, CALL_PHONE, READ_PHONE_STATE, MANAGE_OWN_CALLS no, USE_FULL_SCREEN_INTENT, POST_NOTIFICATIONS, WAKE_LOCK, SEND_SMS no (quick replies hand off to the SMS app with `smsto:`).
The dialer role grants most of these automatically once the user accepts it.
## Data
No schema. Own state in DataStore only. Access: single user; the system enforces the permissions.
## Interne API
| Aufruf | macht | Flow |
| --- | --- | --- |
| `CallLogRepo.observe(filter)` | query CallLog, join contact name/photo by number (PhoneLookup), group by day | F01 |
| `ContactsRepo.favorites()` / `search(q)` / `t9(digits)` | starred contacts; name/number search; T9 digit match on names and numbers | F02, F03, F06 |
| `Dialer.call(number)` | `placeCall(tel:)` | F01–F03 |
| `CallManager` (in `InCallService`) | StateFlow of calls; answer, reject (with SMS), hold, unhold, mute, route, DTMF, disconnect | F04, F05, F07 |
| `Role.request()` | role request intent | F08 |
## Was beißt
- **Wrong default dialer state**: without the role, calls still work through the system dialer; the app shows a banner instead of failing.
- **Lock screen**: incoming UI via full-screen-intent notification; `showWhenLocked` + `turnScreenOn` on the activity.
- **Ringtone**: the system rings for the default dialer? No: the InCallService app must play the ringtone itself only when `Call.Details` say so; on modern Android the platform `Ringer` plays it for the default dialer. We do not play our own.
- **Process death mid-call**: the InCallService is bound by the system as long as calls exist; UI state is rebuilt from `calls`.
- **Emergency numbers**: always via `placeCall`, never blocked by our UI.
- **Privacy**: no network, no analytics; call log stays in the system.
## Build-Reihenfolge
1. Slice: role request, InCallService, incoming + in-call screens, place a call from the keypad.
2. Must: call log with grouping and call back, favourites, search, notifications, mute/speaker/DTMF, proximity.
3. Should: filters, T9, hold, call waiting, quick reply SMS, details screen, voicemail, Bluetooth.
+71
View File
@@ -0,0 +1,71 @@
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
package app.telefon.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
object WColor {
val bg = Color(0xFFF6F7FB)
val card = Color(0xFFFFFFFF)
val text = Color(0xFF191C20)
val textMuted = Color(0xFF44474E)
val missed = Color(0xFFBA1A1A)
val chipOn = Color(0xFFD3E3FD)
val onChipOn = Color(0xFF041E49)
val accept = Color(0xFF1E6B38)
val onAccept = Color(0xFFFFFFFF)
val hangup = Color(0xFFC5221F)
val onHangup = Color(0xFFFFFFFF)
val toggle = Color(0xFFFFFFFF)
val onToggle = Color(0xFF191C20)
val toggleOn = Color(0xFFD3E3FD)
val onToggleOn = Color(0xFF041E49)
val slideTrack = Color(0xFFE2E5EC)
val onSlideTrack = Color(0xFF191C20)
val darkBg = Color(0xFF111318)
val darkCard = Color(0xFF1D2024)
val darkText = Color(0xFFE2E2E9)
val darkTextMuted = Color(0xFFC4C6D0)
val darkMissed = Color(0xFFFFB4AB)
val darkChipOn = Color(0xFF2F4A72)
val darkOnChipOn = Color(0xFFD6E3FF)
val darkAccept = Color(0xFF6FDC8C)
val darkOnAccept = Color(0xFF00391A)
val darkHangup = Color(0xFFF2645A)
val darkOnHangup = Color(0xFF410002)
val darkToggle = Color(0xFF2B2F36)
val darkOnToggle = Color(0xFFE2E2E9)
val darkToggleOn = Color(0xFFA8C7FA)
val darkOnToggleOn = Color(0xFF062E6F)
val darkSlideTrack = Color(0xFF2B2F36)
val darkOnSlideTrack = Color(0xFFE2E2E9)
}
object WType {
val label = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.W500)
val bodySm = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W400)
val body = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
val title = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
val caller = TextStyle(fontSize = 40.sp, lineHeight = 48.sp, fontWeight = FontWeight.W400)
val dial = TextStyle(fontSize = 36.sp, lineHeight = 44.sp, fontWeight = FontWeight.W400)
val key = TextStyle(fontSize = 30.sp, lineHeight = 36.sp, fontWeight = FontWeight.W400)
}
object WSpace {
val s0 = 0.dp
val s4 = 4.dp
val s8 = 8.dp
val s12 = 12.dp
val s16 = 16.dp
val s24 = 24.dp
val s32 = 32.dp
}
object WRadius {
val cardOuter = 24.dp
val cardInner = 4.dp
val pill = 999.dp
}
+8
View File
@@ -0,0 +1,8 @@
# Komponenten: Telefon
Avatars: photo from contacts, else initial on a colour derived from the name (stable), unknown number = person icon. On call screens the avatar is a large scalloped cookie (12 waves).
CallLogGroup: rows inside one card per day, outer radius 24, inner 4, 2dp gaps (same kit as the settings tiles). Row: avatar 40, name (or formatted number), line 2 = direction icon + label/type + time, missed in error colour, trailing call icon button.
SlideToAnswer: track pill, labels "Ablehnen" left / "Annehmen" right, thumb with phone icon; past 50 % acts; custom accessibility actions.
InCallControls: 4 round toggles 64dp with label below (Wähltasten, Stumm, Lautsprecher, Mehr), red hang-up pill 160×64 below; "Mehr" sheet: Halten, Anruf hinzufügen.
Keypad: 3×4 keys (digit 30sp + letters 11sp), green round call FAB 72dp, backspace.
```
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""tokens.json -> Tokens.kt (Compose). Re-run after every token change."""
import json, re, sys, pathlib
src = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "tokens.json")
out = pathlib.Path(sys.argv[2] if len(sys.argv) > 2 else src.with_name("Tokens.kt"))
t = json.loads(src.read_text())
camel = lambda s: re.sub(r"-(\w)", lambda m: m.group(1).upper(), s)
L = ["// Generated from tokens.json by gen_theme.py. Do not edit by hand.",
"package app.telefon.ui.theme", "",
"import androidx.compose.ui.graphics.Color",
"import androidx.compose.ui.text.TextStyle",
"import androidx.compose.ui.text.font.FontWeight",
"import androidx.compose.ui.unit.dp",
"import androidx.compose.ui.unit.sp", "",
"object WColor {"]
L += [f" val {camel(k)} = Color(0xFF{v.lstrip('#').upper()})" for k, v in t["color"].items()]
L += ["}", "", "object WType {"]
for k, v in t["type"].items():
L.append(f" val {camel(k)} = TextStyle(fontSize = {v['size']}.sp, lineHeight = {v['line']}.sp, fontWeight = FontWeight.W{v['weight']})")
L += ["}", "", "object WSpace {"]
L += [f" val s{v} = {v}.dp" for v in t["space"]]
L += ["}", "", "object WRadius {"]
L += [f" val {camel(k)} = {v}.dp" for k, v in t["radius"].items()]
L += ["}", ""]
out.write_text("\n".join(L))
print("wrote", out)
+26
View File
@@ -0,0 +1,26 @@
{
"_about": "Telefon tokens from replica/telefon/screens/ref-01..03. Fallback palette; dynamic colour on Android 12+. Check with contrast.py.",
"color": {
"bg": "#f6f7fb", "card": "#ffffff", "text": "#191c20", "text-muted": "#44474e", "missed": "#ba1a1a",
"chip-on": "#d3e3fd", "on-chip-on": "#041e49", "accept": "#1e6b38", "on-accept": "#ffffff",
"hangup": "#c5221f", "on-hangup": "#ffffff", "toggle": "#ffffff", "on-toggle": "#191c20", "toggle-on": "#d3e3fd", "on-toggle-on": "#041e49",
"slide-track": "#e2e5ec", "on-slide-track": "#191c20",
"dark-bg": "#111318", "dark-card": "#1d2024", "dark-text": "#e2e2e9", "dark-text-muted": "#c4c6d0", "dark-missed": "#ffb4ab",
"dark-chip-on": "#2f4a72", "dark-on-chip-on": "#d6e3ff", "dark-accept": "#6fdc8c", "dark-on-accept": "#00391a",
"dark-hangup": "#f2645a", "dark-on-hangup": "#410002", "dark-toggle": "#2b2f36", "dark-on-toggle": "#e2e2e9", "dark-toggle-on": "#a8c7fa", "dark-on-toggle-on": "#062e6f",
"dark-slide-track": "#2b2f36", "dark-on-slide-track": "#e2e2e9"
},
"pairs": [
["text","bg"],["text","card"],["text-muted","card"],["missed","card"],["on-chip-on","chip-on"],["on-accept","accept"],["on-hangup","hangup"],
["on-toggle","toggle"],["on-toggle-on","toggle-on"],["on-slide-track","slide-track"],
["dark-text","dark-bg"],["dark-text","dark-card"],["dark-text-muted","dark-card"],["dark-missed","dark-card"],["dark-on-chip-on","dark-chip-on"],
["dark-on-accept","dark-accept"],["dark-on-hangup","dark-hangup"],["dark-on-toggle","dark-toggle"],["dark-on-toggle-on","dark-toggle-on"],["dark-on-slide-track","dark-slide-track"]
],
"type": {
"label": {"size": 12, "line": 16, "weight": 500}, "body-sm": {"size": 14, "line": 20, "weight": 400}, "body": {"size": 16, "line": 24, "weight": 400},
"title": {"size": 22, "line": 28, "weight": 400}, "caller": {"size": 40, "line": 48, "weight": 400}, "dial": {"size": 36, "line": 44, "weight": 400}, "key": {"size": 30, "line": 36, "weight": 400}
},
"space": [0, 4, 8, 12, 16, 24, 32],
"radius": {"card-outer": 24, "card-inner": 4, "pill": 999},
"layout": {"avatar-row": 56, "avatar-call": 200}
}