replica(telefon): architecture, tokens (20 pairs AA), components
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user