7 Commits
Author SHA1 Message Date
MrBlakeandClaude Opus 5.5 09b3a00207 build(messages): default SMS app: receive, send multipart with status, threads, chat, notifications with reply, search, archive
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:55:16 +02:00
MrBlakeandClaude Opus 5.5 2f1ddd0ff0 replica(messages): recon, features, architecture, tokens
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:49:43 +02:00
MrBlakeandClaude Opus 5.5 612c017c2b fix(telefon): ongoing-call notification crashed the InCallService; 0.1.2
CallStyle.forOngoingCall is only allowed with a foreground service or a full-screen intent and
throws otherwise. The crash on every outgoing call made Telecom fall back to the system phone app.
The call path now logs failures instead of crashing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:46:40 +02:00
MrBlakeandClaude Opus 5.5 a332ca9c23 fix(telefon): ask for the phone role on first start and before calling without it; 0.1.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:41:47 +02:00
MrBlakeandClaude Opus 5.5 830e653690 build(telefon): default phone app with InCallService, call log, favourites, keypad, details
uhr: handle the public AlarmClock intents (set/show alarm and timer, dismiss, snooze); 0.2.1
docs: how each app replaces the system app

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:30:12 +02:00
MrBlakeandClaude Opus 5.5 0f709aaff8 replica(telefon): architecture, tokens (20 pairs AA), components
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:21:50 +02:00
MrBlakeandClaude Opus 5.5 43030575c4 replica(telefon): recon, feature matrix, reference screenshots
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:21:18 +02:00
97 changed files with 4488 additions and 5 deletions
+72
View File
@@ -8,6 +8,8 @@
| App | Replaces | Status | Install | | App | Replaces | Status | Install |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| **Messages** (`messages/`) | Google Messages (SMS) | alpha, not yet tested with real SMS | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.messages%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Messages%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.messages%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EMessages%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Emessages-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
| **Telefon** (`telefon/`) | Google Phone | alpha, not yet tested with real calls | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.telefon%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Telefon%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.telefon%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5ETelefon%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Etelefon-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
| **Rechner** (`rechner/`) | Google Calculator | alpha | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.rechner%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Rechner%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.rechner%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5ERechner%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Erechner-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet | | **Rechner** (`rechner/`) | Google Calculator | alpha | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.rechner%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Rechner%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.rechner%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5ERechner%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Erechner-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
| **Uhr** (`uhr/`) | Google Clock / Pixel Clock | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.uhr%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Uhr%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.uhr%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EUhr%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Euhr-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet | | **Uhr** (`uhr/`) | Google Clock / Pixel Clock | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.uhr%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Uhr%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.uhr%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EUhr%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Euhr-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
| **Wetter** (`wetter/`) | Google Weather / Pixel Weather | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet | | **Wetter** (`wetter/`) | Google Weather / Pixel Weather | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
@@ -44,6 +46,40 @@ A weather app with the look and feel of Pixel Weather: an animated sky that chan
Requires Android 10 (API 29) or newer. Requires Android 10 (API 29) or newer.
### Messages
An SMS app with the look of Google Messages, made to replace the system messaging app.
- **Becomes your default SMS app** (Android's SMS role): receives and stores texts, sends long messages in parts, shows sending / delivered / failed (tap to retry), and handles "decline with message" from the phone app.
- **Conversations** with unread markers and contact names, **chat** with grouped bubbles and day separators, **new chat** from contacts or a number, **search**, **archive** and **delete**, call from the chat.
- **Notifications** with direct reply and "mark as read".
- **No network permission**; messages stay in Android's own SMS database.
- Not included: RCS (Google/carrier backend), spam protection, Gemini features. MMS (pictures, group MMS) comes later.
<p align="center">
<img src="docs/screenshots/messages/S01-list-light.png" width="200" alt="Conversations">
<img src="docs/screenshots/messages/S02-chat-light.png" width="200" alt="Chat">
<img src="docs/screenshots/messages/S02-chat-dark.png" width="200" alt="Chat, dark">
</p>
### Telefon
A phone app in the style of the Pixel dialer, made to replace the system phone app.
- **Becomes your default phone app** (Android's dialer role): shows incoming calls full screen over the lock screen with slide to answer or decline, the in-call screen with mute, speaker, Bluetooth, hold and keypad, and call notifications. The ringtone is played by Android itself.
- **Start:** search, filters (all, missed, contacts), favourites from your starred contacts, call history grouped by day with missed calls in red; repeated calls fold into one row.
- **Keypad** with T9 contact suggestions, **voicemail** dials your SIM's mailbox number, **details** per contact or number with history, SMS, favourite toggle and "add contact".
- **Your data stays in the system.** Call history and contacts are read from Android's own databases, nothing is copied, and there is no network permission. Declining with a quick reply hands the text to your SMS app.
- Not included: spam detection, call screening, recording and live translation (Google services).
<p align="center">
<img src="docs/screenshots/telefon/S01-home-light.png" width="200" alt="Start with favourites and call history">
<img src="docs/screenshots/telefon/S06-incoming-light.png" width="200" alt="Incoming call">
<img src="docs/screenshots/telefon/S07-incall-dark.png" width="200" alt="In call">
</p>
<sub>Screenshots use made-up contacts.</sub>
### Rechner ### Rechner
A calculator with the flow of the Pixel calculator: live result while typing, a scientific row that expands into a full panel, and the history in a sheet you pull down from the display. A calculator with the flow of the Pixel calculator: live result while typing, a scientific row that expands into a full panel, and the history in a sheet you pull down from the display.
@@ -88,6 +124,18 @@ A clock and alarm app with the look and flow of the Pixel clock: alarms in expan
Requires Android 10 (API 29) or newer. Requires Android 10 (API 29) or newer.
## Replacing the system apps
Each app plugs into the places Android uses to pick an app, so you can uninstall or disable the Google version (on GrapheneOS most are not installed anyway):
| App | How it takes over |
| --- | --- |
| Messages | Android's default SMS app role (Settings → Apps → Default apps → SMS app), plus `sms:`/`smsto:` links and sharing text |
| Telefon | Android's default phone app role (Settings → Apps → Default apps → Phone app), plus `tel:` links and the dial intent |
| Uhr | the public alarm clock intents: set alarm, set timer, show alarms/timers, dismiss and snooze, used by voice assistants, calendars and other apps; exact alarms shown in the status bar and lock screen |
| Rechner | the system calculator category, so the calculator key of keyboards and launchers opens it |
| Wetter | no system role exists for weather; its widgets and the "at a glance"-style clock widget replace the Google ones |
## Install with Obtainium ## Install with Obtainium
[Obtainium](https://github.com/ImranR98/Obtainium) installs and updates apps straight from this repository's releases, with no store in between. [Obtainium](https://github.com/ImranR98/Obtainium) installs and updates apps straight from this repository's releases, with no store in between.
@@ -131,6 +179,26 @@ Filters: `^Rechner ` (release titles) and `^rechner-.*\.apk$` (APKs). Raw link:
obtainium://app/%7B%22id%22%3A%22app.rechner%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Rechner%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.rechner%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5ERechner%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Erechner-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D obtainium://app/%7B%22id%22%3A%22app.rechner%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Rechner%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.rechner%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5ERechner%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Erechner-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D
``` ```
### Telefon
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="54">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.telefon%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Telefon%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.telefon%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5ETelefon%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Etelefon-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D)
Filters: `^Telefon ` and `^telefon-.*\.apk$`. Raw link:
```
obtainium://app/%7B%22id%22%3A%22app.telefon%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Telefon%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.telefon%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5ETelefon%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Etelefon-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D
```
### Messages
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="54">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.messages%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Messages%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.messages%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EMessages%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Emessages-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D)
Filters: `^Messages ` and `^messages-.*\.apk$`. Raw link:
```
obtainium://app/%7B%22id%22%3A%22app.messages%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Messages%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.messages%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EMessages%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Emessages-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D
```
**Releases.** Every app is published as a release titled `<App> <version>` (for example `Wetter 0.1.0`, tag `wetter-v0.1.0`) with one asset named `<app>-<version>.apk`. Obtainium shows *"no matching version"* until the first release of an app exists. **Releases.** Every app is published as a release titled `<App> <version>` (for example `Wetter 0.1.0`, tag `wetter-v0.1.0`) with one asset named `<app>-<version>.apk`. Obtainium shows *"no matching version"* until the first release of an app exists.
**F-Droid:** not available yet. The apps contain no proprietary libraries, so an F-Droid build is planned (either the main repository or a self-hosted F-Droid repo). **F-Droid:** not available yet. The apps contain no proprietary libraries, so an F-Droid build is planned (either the main repository or a self-hosted F-Droid repo).
@@ -149,6 +217,8 @@ Every app is rebuilt with **[Replica](https://github.com/Jakeschincariol/replica
| 4 | `replica-build` | The app itself, screen by screen, with a [build log](replica/wetter/build-log.md) and [screenshots](replica/wetter/clone-screens/) rendered from a real API response | | 4 | `replica-build` | The app itself, screen by screen, with a [build log](replica/wetter/build-log.md) and [screenshots](replica/wetter/clone-screens/) rendered from a real API response |
| next | `replica-test`, `replica-diff`, `replica-entrepreneur`, `replica-brand`, `replica-launch`, `replica-deploy`, `replica-backend` | QA, parity scoring against the original, reading real user reviews for improvements, naming and branding, store listing, release | | next | `replica-test`, `replica-diff`, `replica-entrepreneur`, `replica-brand`, `replica-launch`, `replica-deploy`, `replica-backend` | QA, parity scoring against the original, reading real user reviews for improvements, naming and branding, store listing, release |
**Telefon** as well: [recon](replica/telefon/recon.md), [architecture](replica/telefon/architecture.md), [tokens](replica/telefon/design/tokens.json) (20 pairs AA), [build log](replica/telefon/build-log.md).
**Rechner** too: [recon](replica/rechner/recon.md), [architecture](replica/rechner/architecture.md), [tokens](replica/rechner/design/tokens.json) (24 pairs AA), [build log](replica/rechner/build-log.md), [screenshots](replica/rechner/clone-screens/). **Rechner** too: [recon](replica/rechner/recon.md), [architecture](replica/rechner/architecture.md), [tokens](replica/rechner/design/tokens.json) (24 pairs AA), [build log](replica/rechner/build-log.md), [screenshots](replica/rechner/clone-screens/).
**Uhr** went through the same steps from scratch: [recon](replica/uhr/recon.md) (17 screens, 9 flows), [architecture](replica/uhr/architecture.md), [design tokens](replica/uhr/design/tokens.json) (39 pairs AA), [build log](replica/uhr/build-log.md) and [screenshots](replica/uhr/clone-screens/). **Uhr** went through the same steps from scratch: [recon](replica/uhr/recon.md) (17 screens, 9 flows), [architecture](replica/uhr/architecture.md), [design tokens](replica/uhr/design/tokens.json) (39 pairs AA), [build log](replica/uhr/build-log.md) and [screenshots](replica/uhr/clone-screens/).
@@ -187,6 +257,8 @@ Release builds are signed with the project key from `~/.android/nogoogleapps-rel
``` ```
replica/<app>/ recon, architecture, design system, build log, screenshots (one folder per app) replica/<app>/ recon, architecture, design system, build log, screenshots (one folder per app)
wetter/ the weather app (Kotlin, Jetpack Compose, Glance widgets, Room, WorkManager) wetter/ the weather app (Kotlin, Jetpack Compose, Glance widgets, Room, WorkManager)
messages/ the SMS app (Kotlin, Jetpack Compose, system SMS provider)
telefon/ the phone app (Kotlin, Jetpack Compose, InCallService, system call log and contacts)
rechner/ the calculator (Kotlin, Jetpack Compose, own exact expression engine) rechner/ the calculator (Kotlin, Jetpack Compose, own exact expression engine)
uhr/ the clock and alarm app (Kotlin, Jetpack Compose, AlarmManager, Glance, Room) uhr/ the clock and alarm app (Kotlin, Jetpack Compose, AlarmManager, Glance, Room)
``` ```
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
local.properties
.idea/
*.iml
.kotlin/
+93
View File
@@ -0,0 +1,93 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp")
id("io.github.takahirom.roborazzi")
}
android {
namespace = "app.messages"
compileSdk = 36
defaultConfig {
applicationId = "app.messages"
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
val keyProps = Properties().apply {
val f = file(System.getProperty("user.home") + "/.android/nogoogleapps-release.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
signingConfigs {
if (keyProps.isNotEmpty()) create("release") {
storeFile = file(keyProps.getProperty("storeFile"))
storePassword = keyProps.getProperty("storePassword")
keyAlias = keyProps.getProperty("keyAlias")
keyPassword = keyProps.getProperty("keyPassword")
}
}
buildTypes {
debug {
// installs next to the release app from Obtainium
applicationIdSuffix = ".debug"
resValue("string", "app_name", "Messages (Debug)")
}
release {
resValue("string", "app_name", "Messages")
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures { compose = true }
testOptions { unitTests { isIncludeAndroidResources = true } }
}
kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.10.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.11.0")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.4")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4")
implementation("androidx.room:room-runtime:2.7.0")
implementation("androidx.room:room-ktx:2.7.0")
ksp("androidx.room:room-compiler:2.7.0")
implementation("androidx.datastore:datastore-preferences:1.1.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.15.1")
testImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.46.1")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.46.1")
}
roborazzi { outputDir.set(rootProject.file("../replica/messages/clone-screens")) }
View File
+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- no INTERNET: SMS travel over the mobile network through the system, not through this app -->
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.RECEIVE_MMS" />
<uses-permission android:name="android.permission.RECEIVE_WAP_PUSH" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.telephony" android:required="false" />
<application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name" android:supportsRtl="true" android:theme="@android:style/Theme.Material.NoActionBar">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.APP_MESSAGING" />
</intent-filter>
<!-- required for the default SMS app role -->
<intent-filter>
<action android:name="android.intent.action.SENDTO" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="sms" /><data android:scheme="smsto" /><data android:scheme="mms" /><data android:scheme="mmsto" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<receiver android:name=".sms.SmsDeliverReceiver" android:exported="true" android:permission="android.permission.BROADCAST_SMS">
<intent-filter><action android:name="android.provider.Telephony.SMS_DELIVER" /></intent-filter>
</receiver>
<receiver android:name=".sms.MmsDeliverReceiver" android:exported="true" android:permission="android.permission.BROADCAST_WAP_PUSH">
<intent-filter>
<action android:name="android.provider.Telephony.WAP_PUSH_DELIVER" />
<data android:mimeType="application/vnd.wap.mms-message" />
</intent-filter>
</receiver>
<service android:name=".sms.RespondViaMessageService" android:exported="true" android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE">
<intent-filter>
<action android:name="android.intent.action.RESPOND_VIA_MESSAGE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="sms" /><data android:scheme="smsto" /><data android:scheme="mms" /><data android:scheme="mmsto" />
</intent-filter>
</service>
<receiver android:name=".sms.SmsStatusReceiver" android:exported="false" />
<receiver android:name=".sms.NotificationActionReceiver" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,243 @@
package app.messages
import android.Manifest
import android.app.role.RoleManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.provider.ContactsContract
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.Archive
import androidx.compose.material.icons.outlined.Call
import androidx.compose.material.icons.outlined.Chat
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Unarchive
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import app.messages.data.Message
import app.messages.data.Sms
import app.messages.sms.Notifier
import app.messages.sms.Sender
import app.messages.ui.Conversation
import app.messages.ui.ConversationList
import app.messages.ui.NewChat
import app.messages.ui.SearchResults
import app.messages.ui.components.Avatar
import app.messages.ui.theme.MsgTheme
import app.messages.ui.theme.WType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** Also the target of sms:/smsto: links (required for the default SMS app role). */
class MainActivity : ComponentActivity() {
private val open = mutableStateOf<Pair<String, String>?>(null) // address to draft
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
Notifier.channel(this)
handle(intent)
setContent { MsgTheme { App(open.value) { open.value = null } } }
}
override fun onNewIntent(intent: Intent) { super.onNewIntent(intent); handle(intent) }
private fun handle(i: Intent?) {
i ?: return
val address = when (i.action) {
Intent.ACTION_SENDTO, Intent.ACTION_VIEW -> i.data?.schemeSpecificPart?.substringBefore('?')?.takeIf { i.data?.scheme in setOf("sms", "smsto", "mms", "mmsto") }
Intent.ACTION_SEND -> ""
else -> i.getStringExtra(Notifier.EXTRA_ADDRESS)
} ?: return
val body = i.getStringExtra("sms_body") ?: i.getStringExtra(Intent.EXTRA_TEXT) ?: ""
open.value = Uri.decode(address) to body
}
}
private sealed interface Route {
data class Chat(val address: String, val draft: String = "") : Route
data object New : Route
data object Search : Route
data object Archive : Route
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun App(request: Pair<String, String>?, onHandled: () -> Unit) {
val ctx = LocalContext.current
val role = ctx.getSystemService(RoleManager::class.java)
var isDefault by remember { mutableStateOf(role.isRoleHeld(RoleManager.ROLE_SMS)) }
var granted by remember { mutableStateOf(has(ctx, Manifest.permission.READ_SMS)) }
val perms = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted = has(ctx, Manifest.permission.READ_SMS) }
val roleLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
isDefault = role.isRoleHeld(RoleManager.ROLE_SMS); granted = has(ctx, Manifest.permission.READ_SMS)
if (!has(ctx, Manifest.permission.READ_CONTACTS)) perms.launch(arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.POST_NOTIFICATIONS))
}
var asked by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (!isDefault && !asked && role.isRoleAvailable(RoleManager.ROLE_SMS)) { asked = true; roleLauncher.launch(role.createRequestRoleIntent(RoleManager.ROLE_SMS)) }
else if (!granted) perms.launch(arrayOf(Manifest.permission.READ_SMS, Manifest.permission.READ_CONTACTS, Manifest.permission.POST_NOTIFICATIONS))
}
var route by remember { mutableStateOf<Route?>(null) }
LaunchedEffect(request) { request?.let { (a, b) -> route = if (a.isBlank()) Route.New else Route.Chat(a, b); onHandled() } }
val threads by remember(granted, isDefault) { Sms.threads(ctx) }.collectAsState(initial = emptyList())
var selected by remember { mutableStateOf(setOf<Long>()) }
var confirmDelete by remember { mutableStateOf(false) }
val back: () -> Unit = { route = if (route is Route.Chat || route is Route.Search) null else null }
if (route != null) BackHandler(onBack = back)
if (selected.isNotEmpty()) BackHandler { selected = emptySet() }
when (val r = route) {
is Route.Chat -> ChatScreen(r.address, r.draft, isDefault, onBack = back) { roleLauncher.launch(role.createRequestRoleIntent(RoleManager.ROLE_SMS)) }
Route.New -> Scaffold(topBar = { TopAppBar({ Text(stringResource(R.string.new_chat)) }, navigationIcon = { BackBtn(back) }) }) { pad ->
val contacts by produceState(emptyList<Pair<String, String>>()) { value = withContext(Dispatchers.IO) { contacts(ctx) } }
NewChat(pad, contacts) { route = Route.Chat(it) }
}
Route.Search -> Scaffold(topBar = { TopAppBar({ Text(stringResource(R.string.search)) }, navigationIcon = { BackBtn(back) }) }) { pad ->
SearchResults(pad, { Sms.search(ctx, it) }, { Sms.contact(ctx, it)?.first ?: it }) { m -> route = Route.Chat(m.address) }
}
Route.Archive, null -> {
val archive = r == Route.Archive
var menu by remember { mutableStateOf(false) }
Scaffold(
topBar = {
if (selected.isNotEmpty()) TopAppBar({ Text("${selected.size}") }, navigationIcon = { IconButton({ selected = emptySet() }) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, null) } },
actions = {
IconButton({ selected.forEach { Sms.setArchived(ctx, it, !archive) }; selected = emptySet() }) { Icon(if (archive) Icons.Outlined.Unarchive else Icons.Outlined.Archive, stringResource(R.string.archive)) }
IconButton({ confirmDelete = true }) { Icon(Icons.Outlined.Delete, stringResource(R.string.delete)) }
})
else TopAppBar({ Text(stringResource(if (archive) R.string.archived else R.string.app_title)) },
navigationIcon = { if (archive) BackBtn { route = null } },
actions = {
IconButton({ route = Route.Search }) { Icon(Icons.Rounded.Search, stringResource(R.string.search)) }
IconButton({ menu = true }) { Icon(Icons.Rounded.MoreVert, null) }
DropdownMenu(menu, { menu = false }) {
DropdownMenuItem(text = { Text(stringResource(R.string.archived)) }, onClick = { menu = false; route = Route.Archive })
}
})
},
floatingActionButton = {
if (!archive) ExtendedFloatingActionButton(onClick = { route = Route.New }, icon = { Icon(Icons.Outlined.Chat, null) }, text = { Text(stringResource(R.string.start_chat)) },
containerColor = MaterialTheme.colorScheme.primaryContainer)
},
) { pad ->
Column {
if (!isDefault && !archive) Column(
Modifier.padding(start = 16.dp, end = 16.dp, top = pad.calculateTopPadding() + 8.dp).clip(RoundedCornerShape(24.dp)).background(MaterialTheme.colorScheme.primaryContainer)
.clickable { roleLauncher.launch(role.createRequestRoleIntent(RoleManager.ROLE_SMS)) }.padding(16.dp),
) {
Text(stringResource(R.string.default_title), style = WType.body, color = MaterialTheme.colorScheme.onPrimaryContainer)
Text(stringResource(R.string.default_text), style = WType.bodySm, color = MaterialTheme.colorScheme.onPrimaryContainer)
}
ConversationList(threads.filter { it.archived == archive }, if (!isDefault && !archive) PaddingValues(bottom = pad.calculateBottomPadding()) else pad,
onOpen = { t -> if (selected.isNotEmpty()) selected = selected.toggle(t.id) else route = Route.Chat(t.address) },
onLongPress = { selected = selected.toggle(it.id) }, selected = selected)
}
}
}
}
if (confirmDelete) AlertDialog({ confirmDelete = false }, title = { Text(stringResource(R.string.delete_confirm, selected.size)) },
confirmButton = { TextButton({ selected.forEach { Sms.deleteThread(ctx, it) }; selected = emptySet(); confirmDelete = false }) { Text(stringResource(R.string.delete)) } },
dismissButton = { TextButton({ confirmDelete = false }) { Text(stringResource(android.R.string.cancel)) } })
}
private fun Set<Long>.toggle(id: Long) = if (id in this) this - id else this + id
@Composable private fun BackBtn(onClick: () -> Unit) = IconButton(onClick) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChatScreen(address: String, draft: String, isDefault: Boolean, onBack: () -> Unit, onMakeDefault: () -> Unit) {
val ctx = LocalContext.current
val threadId = remember(address) { runCatching { Sms.threadIdFor(ctx, address) }.getOrDefault(-1L) }
val messages by remember(threadId) { Sms.messages(ctx, threadId) }.collectAsState(initial = emptyList())
LaunchedEffect(threadId, messages.size) { if (isDefault) { Sms.markRead(ctx, threadId); Notifier.cancel(ctx, threadId) } }
val who = remember(address) { Sms.contact(ctx, address) }
var menuFor by remember { mutableStateOf<Message?>(null) }
var needRole by remember { mutableStateOf(false) }
Scaffold(topBar = {
TopAppBar(title = {
androidx.compose.foundation.layout.Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Avatar(who?.first ?: address, who?.second, 36.dp)
Text(" " + (who?.first ?: address), style = WType.title.copy(fontSize = WType.body.fontSize * 1.15f))
}
}, navigationIcon = { BackBtn(onBack) }, actions = {
if (address.any(Char::isDigit)) IconButton({ ctx.startActivity(Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", address, null))) }) { Icon(Icons.Outlined.Call, stringResource(R.string.call)) }
})
}) { pad ->
Conversation(messages, pad, onSend = { text -> if (isDefault) Sender.send(ctx, address, text) else needRole = true },
onRetry = { m -> if (isDefault) { Sms.deleteMessage(ctx, m.id); Sender.send(ctx, address, m.body) } }, onLong = { menuFor = it }, draft = draft)
}
menuFor?.let { m ->
AlertDialog({ menuFor = null }, text = {
Column {
TextButton({ ctx.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("SMS", m.body)); menuFor = null }) { Text(stringResource(R.string.copy)) }
TextButton({ Sms.deleteMessage(ctx, m.id); menuFor = null }) { Text(stringResource(R.string.delete)) }
}
}, confirmButton = {})
}
if (needRole) AlertDialog({ needRole = false }, title = { Text(stringResource(R.string.default_title)) }, text = { Text(stringResource(R.string.need_role_send)) },
confirmButton = { TextButton({ needRole = false; onMakeDefault() }) { Text(stringResource(R.string.make_default)) } },
dismissButton = { TextButton({ needRole = false }) { Text(stringResource(android.R.string.cancel)) } })
}
private fun has(ctx: Context, p: String) = ContextCompat.checkSelfPermission(ctx, p) == PackageManager.PERMISSION_GRANTED
private fun contacts(ctx: Context): List<Pair<String, String>> {
if (!has(ctx, Manifest.permission.READ_CONTACTS)) return emptyList()
val out = mutableListOf<Pair<String, String>>()
runCatching {
ctx.contentResolver.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
arrayOf(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER), null, null,
"${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} COLLATE LOCALIZED ASC")?.use { c -> while (c.moveToNext()) out += c.getString(0).orEmpty() to c.getString(1).orEmpty() }
}
return out.distinctBy { it.second.filter(Char::isDigit) }
}
@@ -0,0 +1,152 @@
package app.messages.data
import android.Manifest
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.database.ContentObserver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.ContactsContract
import android.provider.Telephony
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
enum class Box { INBOX, SENT, OUTBOX, FAILED, QUEUED, DRAFT }
data class Message(val id: Long, val threadId: Long, val address: String, val body: String, val date: Long, val box: Box, val read: Boolean, val status: Int)
data class Thread(val id: Long, val address: String, val name: String?, val photo: Uri?, val snippet: String, val date: Long, val unread: Int, val lastFromMe: Boolean, val archived: Boolean)
private fun Context.has(p: String) = ContextCompat.checkSelfPermission(this, p) == PackageManager.PERMISSION_GRANTED
private fun <T> Context.observe(uri: Uri, load: () -> T): Flow<T> = callbackFlow {
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean) { trySend(load()) } }
contentResolver.registerContentObserver(uri, true, obs)
trySend(load())
awaitClose { contentResolver.unregisterContentObserver(obs) }
}.flowOn(Dispatchers.IO)
/** All SMS data lives in Android's own provider; this app only reads and (as default SMS app) writes it. */
object Sms {
private val nameCache = mutableMapOf<String, Pair<String, Uri?>?>()
fun contact(context: Context, number: String): Pair<String, Uri?>? = nameCache.getOrPut(number) {
if (number.isBlank() || !context.has(Manifest.permission.READ_CONTACTS)) return@getOrPut null
runCatching {
context.contentResolver.query(Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)),
arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME, ContactsContract.PhoneLookup.PHOTO_URI), null, null, null)?.use { c ->
if (c.moveToFirst()) c.getString(0) to c.getString(1)?.let(Uri::parse) else null
}
}.getOrNull()
}
private fun box(t: Int) = when (t) {
Telephony.Sms.MESSAGE_TYPE_SENT -> Box.SENT; Telephony.Sms.MESSAGE_TYPE_OUTBOX -> Box.OUTBOX; Telephony.Sms.MESSAGE_TYPE_FAILED -> Box.FAILED
Telephony.Sms.MESSAGE_TYPE_QUEUED -> Box.QUEUED; Telephony.Sms.MESSAGE_TYPE_DRAFT -> Box.DRAFT; else -> Box.INBOX
}
/** One row per thread, newest first: built from the SMS table (works on every Android build). */
fun threads(context: Context): Flow<List<Thread>> = context.observe(Telephony.Sms.CONTENT_URI) { loadThreads(context) }
private fun loadThreads(context: Context): List<Thread> {
if (!context.has(Manifest.permission.READ_SMS)) return emptyList()
val archived = archivedIds(context)
val latest = linkedMapOf<Long, Thread>()
val unread = mutableMapOf<Long, Int>()
runCatching {
context.contentResolver.query(Telephony.Sms.CONTENT_URI,
arrayOf(Telephony.Sms.THREAD_ID, Telephony.Sms.ADDRESS, Telephony.Sms.BODY, Telephony.Sms.DATE, Telephony.Sms.TYPE, Telephony.Sms.READ),
null, null, "${Telephony.Sms.DATE} DESC")?.use { c ->
while (c.moveToNext()) {
val tid = c.getLong(0)
if (c.getInt(5) == 0 && box(c.getInt(4)) == Box.INBOX) unread[tid] = (unread[tid] ?: 0) + 1
if (tid in latest) continue
val address = c.getString(1).orEmpty()
val who = contact(context, address)
latest[tid] = Thread(tid, address, who?.first, who?.second, c.getString(2).orEmpty(), c.getLong(3), 0, box(c.getInt(4)) != Box.INBOX, tid in archived)
}
}
}
return latest.values.map { it.copy(unread = unread[it.id] ?: 0) }
}
fun messages(context: Context, threadId: Long): Flow<List<Message>> = context.observe(Telephony.Sms.CONTENT_URI) {
val out = mutableListOf<Message>()
runCatching {
context.contentResolver.query(Telephony.Sms.CONTENT_URI,
arrayOf(Telephony.Sms._ID, Telephony.Sms.THREAD_ID, Telephony.Sms.ADDRESS, Telephony.Sms.BODY, Telephony.Sms.DATE, Telephony.Sms.TYPE, Telephony.Sms.READ, Telephony.Sms.STATUS),
"${Telephony.Sms.THREAD_ID} = ?", arrayOf(threadId.toString()), "${Telephony.Sms.DATE} ASC")?.use { c ->
while (c.moveToNext()) out += Message(c.getLong(0), c.getLong(1), c.getString(2).orEmpty(), c.getString(3).orEmpty(), c.getLong(4), box(c.getInt(5)), c.getInt(6) == 1, c.getInt(7))
}
}
out
}
fun search(context: Context, q: String): List<Message> {
if (q.length < 2) return emptyList()
val out = mutableListOf<Message>()
runCatching {
context.contentResolver.query(Telephony.Sms.CONTENT_URI,
arrayOf(Telephony.Sms._ID, Telephony.Sms.THREAD_ID, Telephony.Sms.ADDRESS, Telephony.Sms.BODY, Telephony.Sms.DATE, Telephony.Sms.TYPE, Telephony.Sms.READ, Telephony.Sms.STATUS),
"${Telephony.Sms.BODY} LIKE ?", arrayOf("%$q%"), "${Telephony.Sms.DATE} DESC LIMIT 100")?.use { c ->
while (c.moveToNext()) out += Message(c.getLong(0), c.getLong(1), c.getString(2).orEmpty(), c.getString(3).orEmpty(), c.getLong(4), box(c.getInt(5)), c.getInt(6) == 1, c.getInt(7))
}
}
return out
}
fun threadIdFor(context: Context, address: String): Long = Telephony.Threads.getOrCreateThreadId(context, address)
fun insertIncoming(context: Context, address: String, body: String, sentAt: Long, subId: Int): Uri? = context.contentResolver.insert(
Telephony.Sms.Inbox.CONTENT_URI,
ContentValues().apply {
put(Telephony.Sms.ADDRESS, address); put(Telephony.Sms.BODY, body); put(Telephony.Sms.DATE, System.currentTimeMillis())
put(Telephony.Sms.DATE_SENT, sentAt); put(Telephony.Sms.READ, 0); put(Telephony.Sms.SEEN, 0); put(Telephony.Sms.SUBSCRIPTION_ID, subId)
},
)
fun insertOutgoing(context: Context, address: String, body: String, subId: Int): Uri? = context.contentResolver.insert(
Telephony.Sms.Outbox.CONTENT_URI,
ContentValues().apply {
put(Telephony.Sms.ADDRESS, address); put(Telephony.Sms.BODY, body); put(Telephony.Sms.DATE, System.currentTimeMillis())
put(Telephony.Sms.READ, 1); put(Telephony.Sms.SUBSCRIPTION_ID, subId)
},
)
fun setBox(context: Context, uri: Uri, type: Int) = runCatching {
context.contentResolver.update(uri, ContentValues().apply { put(Telephony.Sms.TYPE, type) }, null, null)
}
fun setDelivered(context: Context, uri: Uri, ok: Boolean) = runCatching {
context.contentResolver.update(uri, ContentValues().apply { put(Telephony.Sms.STATUS, if (ok) Telephony.Sms.STATUS_COMPLETE else Telephony.Sms.STATUS_FAILED) }, null, null)
}
fun markRead(context: Context, threadId: Long) = runCatching {
context.contentResolver.update(Telephony.Sms.CONTENT_URI, ContentValues().apply { put(Telephony.Sms.READ, 1); put(Telephony.Sms.SEEN, 1) },
"${Telephony.Sms.THREAD_ID} = ? AND ${Telephony.Sms.READ} = 0", arrayOf(threadId.toString()))
}
fun deleteThread(context: Context, threadId: Long) = runCatching {
context.contentResolver.delete(Telephony.Sms.CONTENT_URI, "${Telephony.Sms.THREAD_ID} = ?", arrayOf(threadId.toString()))
}
fun deleteMessage(context: Context, id: Long) = runCatching {
context.contentResolver.delete(Uri.withAppendedPath(Telephony.Sms.CONTENT_URI, id.toString()), null, null)
}
// Archive: kept in our own preferences (the provider column is not writable on every build)
private fun prefs(context: Context) = context.getSharedPreferences("archive", Context.MODE_PRIVATE)
fun archivedIds(context: Context): Set<Long> = prefs(context).getStringSet("ids", emptySet())!!.mapNotNull { it.toLongOrNull() }.toSet()
fun setArchived(context: Context, threadId: Long, on: Boolean) {
val s = archivedIds(context).map { it.toString() }.toMutableSet()
if (on) s += threadId.toString() else s -= threadId.toString()
prefs(context).edit().putStringSet("ids", s).apply()
context.contentResolver.notifyChange(Telephony.Sms.CONTENT_URI, null)
}
}
@@ -0,0 +1,62 @@
package app.messages.sms
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import app.messages.MainActivity
import app.messages.R
import app.messages.data.Sms
object Notifier {
const val CH = "messages"
const val ACTION_REPLY = "app.messages.REPLY"
const val ACTION_READ = "app.messages.READ"
const val KEY_REPLY = "reply"
const val EXTRA_THREAD = "thread"
const val EXTRA_ADDRESS = "address"
fun channel(context: Context) = context.getSystemService(NotificationManager::class.java)
.createNotificationChannel(NotificationChannel(CH, context.getString(R.string.ch_messages), NotificationManager.IMPORTANCE_HIGH))
fun incoming(context: Context, threadId: Long, address: String, body: String) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
channel(context)
val name = Sms.contact(context, address)?.first ?: address
val sender = Person.Builder().setName(name).setKey(address).build()
val me = Person.Builder().setName(context.getString(R.string.me)).build()
val open = PendingIntent.getActivity(context, threadId.toInt(),
Intent(context, MainActivity::class.java).putExtra(EXTRA_THREAD, threadId).putExtra(EXTRA_ADDRESS, address).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
fun action(a: String, mutable: Boolean) = PendingIntent.getBroadcast(context, (a + threadId).hashCode(),
Intent(context, NotificationActionReceiver::class.java).setAction(a).putExtra(EXTRA_THREAD, threadId).putExtra(EXTRA_ADDRESS, address),
if (mutable) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val reply = NotificationCompat.Action.Builder(0, context.getString(R.string.reply), action(ACTION_REPLY, true))
.addRemoteInput(RemoteInput.Builder(KEY_REPLY).setLabel(context.getString(R.string.reply_hint)).build())
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY).setShowsUserInterface(false).build()
val read = NotificationCompat.Action.Builder(0, context.getString(R.string.mark_read), action(ACTION_READ, false))
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ).setShowsUserInterface(false).build()
val n = NotificationCompat.Builder(context, CH).setSmallIcon(R.drawable.ic_stat_message)
.setStyle(NotificationCompat.MessagingStyle(me).addMessage(body, System.currentTimeMillis(), sender))
.setContentIntent(open).setAutoCancel(true).setCategory(NotificationCompat.CATEGORY_MESSAGE)
.addAction(reply).addAction(read).setShortcutId(null).build()
context.getSystemService(NotificationManager::class.java).notify(threadId.toInt(), n)
}
fun cancel(context: Context, threadId: Long) = context.getSystemService(NotificationManager::class.java).cancel(threadId.toInt())
fun mmsUnsupported(context: Context) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
channel(context)
context.getSystemService(NotificationManager::class.java).notify(1,
NotificationCompat.Builder(context, CH).setSmallIcon(R.drawable.ic_stat_message)
.setContentTitle(context.getString(R.string.mms_title)).setContentText(context.getString(R.string.mms_text)).setAutoCancel(true).build())
}
}
@@ -0,0 +1,101 @@
package app.messages.sms
import android.app.Activity
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.IBinder
import android.provider.Telephony
import android.telephony.SmsManager
import android.telephony.SubscriptionManager
import androidx.core.app.RemoteInput
import app.messages.data.Sms
/** SMS_DELIVER: only the default SMS app gets this, and must store the message itself. */
class SmsDeliverReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val parts = Telephony.Sms.Intents.getMessagesFromIntent(intent) ?: return
if (parts.isEmpty()) return
val address = parts[0].displayOriginatingAddress.orEmpty()
val body = parts.joinToString("") { it.displayMessageBody.orEmpty() }
val subId = intent.getIntExtra("subscription", SubscriptionManager.getDefaultSmsSubscriptionId())
val uri = Sms.insertIncoming(context, address, body, parts[0].timestampMillis, subId)
val threadId = Sms.threadIdFor(context, address)
Notifier.incoming(context, threadId, address, body)
android.util.Log.i("Messages", "stored incoming sms $uri")
}
}
/** MMS push: accepted so the role is valid; MMS download is not supported yet. */
class MmsDeliverReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) = Notifier.mmsUnsupported(context)
}
object Sender {
const val ACTION_SENT = "app.messages.SENT"
const val ACTION_DELIVERED = "app.messages.DELIVERED"
/** Writes to the outbox first, so a message never disappears; the receivers move it to sent or failed. */
fun send(context: Context, address: String, text: String, subId: Int = SubscriptionManager.getDefaultSmsSubscriptionId()) {
if (text.isBlank() || address.isBlank()) return
val uri = Sms.insertOutgoing(context, address, text, subId) ?: return
val sm = (if (subId != SubscriptionManager.INVALID_SUBSCRIPTION_ID) context.getSystemService(SmsManager::class.java).createForSubscriptionId(subId)
else context.getSystemService(SmsManager::class.java))
val parts = sm.divideMessage(text)
fun pi(action: String, i: Int) = android.app.PendingIntent.getBroadcast(
context, (uri.toString() + action + i).hashCode(),
Intent(context, SmsStatusReceiver::class.java).setAction(action).setData(uri).putExtra("last", i == parts.lastIndex),
android.app.PendingIntent.FLAG_IMMUTABLE,
)
val sent = ArrayList(parts.indices.map { pi(ACTION_SENT, it) })
val delivered = ArrayList(parts.indices.map { pi(ACTION_DELIVERED, it) })
runCatching { sm.sendMultipartTextMessage(address, null, parts, sent, delivered) }
.onFailure { Sms.setBox(context, uri, Telephony.Sms.MESSAGE_TYPE_FAILED) }
}
}
class SmsStatusReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val uri: Uri = intent.data ?: return
when (intent.action) {
Sender.ACTION_SENT -> {
val ok = resultCode == Activity.RESULT_OK
// any failed part fails the message; the last part's success marks it sent
if (!ok) Sms.setBox(context, uri, Telephony.Sms.MESSAGE_TYPE_FAILED)
else if (intent.getBooleanExtra("last", true)) Sms.setBox(context, uri, Telephony.Sms.MESSAGE_TYPE_SENT)
}
Sender.ACTION_DELIVERED -> if (intent.getBooleanExtra("last", true)) Sms.setDelivered(context, uri, resultCode == Activity.RESULT_OK)
}
}
}
/** Direct reply and "mark as read" from the notification. */
class NotificationActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val threadId = intent.getLongExtra(Notifier.EXTRA_THREAD, -1)
val address = intent.getStringExtra(Notifier.EXTRA_ADDRESS).orEmpty()
when (intent.action) {
Notifier.ACTION_REPLY -> {
val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(Notifier.KEY_REPLY)?.toString().orEmpty()
Sender.send(context, address, text)
Sms.markRead(context, threadId)
Notifier.cancel(context, threadId)
}
Notifier.ACTION_READ -> { Sms.markRead(context, threadId); Notifier.cancel(context, threadId) }
}
}
}
/** "Decline with message" from the phone app; required for the SMS role. */
class RespondViaMessageService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val to = intent?.data?.schemeSpecificPart?.substringBefore('?').orEmpty()
val text = intent?.getStringExtra(Intent.EXTRA_TEXT).orEmpty()
if (to.isNotBlank() && text.isNotBlank()) Sender.send(this, to, text)
stopSelf(startId)
return START_NOT_STICKY
}
}
@@ -0,0 +1,230 @@
package app.messages.ui
import android.text.format.DateUtils
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Send
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import app.messages.R
import app.messages.data.Box as MBox
import app.messages.data.Message
import app.messages.data.Thread
import app.messages.ui.components.Avatar
import app.messages.ui.theme.WType
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
fun shortTime(at: Long): String {
val d = Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault())
return when {
d.toLocalDate() == LocalDate.now() -> d.toLocalTime().format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT))
d.toLocalDate().isAfter(LocalDate.now().minusDays(7)) -> d.format(DateTimeFormatter.ofPattern("EEE"))
else -> d.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
}
}
/** S01 */
@Composable
fun ConversationList(threads: List<Thread>, pad: PaddingValues, onOpen: (Thread) -> Unit, onLongPress: (Thread) -> Unit, selected: Set<Long>) {
if (threads.isEmpty()) {
Box(Modifier.fillMaxSize().padding(pad), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.no_threads), style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
return
}
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = pad.calculateTopPadding(), bottom = pad.calculateBottomPadding() + 96.dp)) {
items(threads, key = { it.id }) { t ->
val unread = t.unread > 0
val sel = t.id in selected
Row(
Modifier.fillMaxWidth().background(if (sel) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent)
.combinedClickable(onClick = { onOpen(t) }, onLongClick = { onLongPress(t) }).padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(t.name ?: t.address, t.photo, 48.dp)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(t.name ?: t.address, style = WType.body.copy(fontWeight = if (unread) FontWeight.Bold else FontWeight.Normal), maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
(if (t.lastFromMe) stringResource(R.string.you_prefix) else "") + t.snippet.replace('\n', ' '),
style = WType.bodySm.copy(fontWeight = if (unread) FontWeight.SemiBold else FontWeight.Normal),
color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(shortTime(t.date), style = WType.label, color = if (unread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
if (unread) Box(Modifier.padding(top = 6.dp).size(10.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary))
}
}
}
}
}
/** S02 */
@Composable
fun Conversation(messages: List<Message>, pad: PaddingValues, onSend: (String) -> Unit, onRetry: (Message) -> Unit, onLong: (Message) -> Unit, draft: String = "") {
var text by rememberSaveable { mutableStateOf(draft) }
val list = rememberLazyListState()
LaunchedEffect(messages.size) { if (messages.isNotEmpty()) list.scrollToItem(messages.size * 2) }
Column(Modifier.fillMaxSize().padding(top = pad.calculateTopPadding()).imePadding().navigationBarsPadding()) {
LazyColumn(Modifier.weight(1f).fillMaxWidth(), state = list, contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
var lastDay: LocalDate? = null
messages.forEachIndexed { i, m ->
val day = Instant.ofEpochMilli(m.date).atZone(ZoneId.systemDefault()).toLocalDate()
if (day != lastDay) {
lastDay = day
item(key = "d${m.id}") {
Text(DateUtils.getRelativeTimeSpanString(m.date, System.currentTimeMillis(), DateUtils.DAY_IN_MILLIS).toString(),
style = WType.label, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center)
}
}
val mine = m.box != MBox.INBOX
val prevSame = messages.getOrNull(i - 1)?.let { (it.box != MBox.INBOX) == mine } == true
val nextSame = messages.getOrNull(i + 1)?.let { (it.box != MBox.INBOX) == mine } == true
item(key = m.id) { Bubble(m, mine, prevSame, nextSame, isLast = !nextSame, onRetry, onLong) }
}
}
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.Bottom) {
TextField(
text, { text = it }, Modifier.weight(1f).heightIn(min = 52.dp).clip(RoundedCornerShape(26.dp)),
placeholder = { Text(stringResource(R.string.compose_hint)) }, maxLines = 6,
colors = TextFieldDefaults.colors(focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer),
)
Spacer(Modifier.width(8.dp))
val can = text.isNotBlank()
Box(Modifier.size(52.dp).clip(CircleShape).background(if (can) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainer)
.clickable(enabled = can) { onSend(text.trim()); text = "" }, contentAlignment = Alignment.Center) {
Icon(Icons.AutoMirrored.Rounded.Send, stringResource(R.string.send), tint = if (can) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun Bubble(m: Message, mine: Boolean, prevSame: Boolean, nextSame: Boolean, isLast: Boolean, onRetry: (Message) -> Unit, onLong: (Message) -> Unit) {
val s = MaterialTheme.colorScheme
val big = 20.dp; val small = 6.dp
val shape = if (mine) RoundedCornerShape(big, if (prevSame) small else big, if (nextSame) small else big, big)
else RoundedCornerShape(if (prevSame) small else big, big, big, if (nextSame) small else big)
val failed = m.box == MBox.FAILED
Column(Modifier.fillMaxWidth().padding(top = if (prevSame) 0.dp else 6.dp), horizontalAlignment = if (mine) Alignment.End else Alignment.Start) {
Box(
Modifier.widthIn(max = 300.dp).clip(shape).background(if (mine) s.primary else s.surfaceContainerHigh)
.combinedClickable(onClick = { if (failed) onRetry(m) }, onLongClick = { onLong(m) }).padding(horizontal = 14.dp, vertical = 10.dp),
) { Text(m.body, style = WType.body, color = if (mine) s.onPrimary else s.onSurface) }
if (isLast || failed) {
val status = when {
failed -> stringResource(R.string.failed_retry)
m.box == MBox.OUTBOX || m.box == MBox.QUEUED -> stringResource(R.string.sending)
mine && m.status == android.provider.Telephony.Sms.STATUS_COMPLETE -> stringResource(R.string.delivered) + " · " + shortTime(m.date)
else -> shortTime(m.date)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) {
if (failed) Icon(Icons.Rounded.ErrorOutline, null, Modifier.size(14.dp), tint = s.error)
Text(" $status", style = WType.label, color = if (failed) s.error else s.onSurfaceVariant)
}
}
}
}
/** S03 */
@Composable
fun NewChat(pad: PaddingValues, contacts: List<Pair<String, String>>, onPick: (String) -> Unit) {
var q by rememberSaveable { mutableStateOf("") }
val hits = remember(q, contacts) { contacts.filter { (n, num) -> q.isBlank() || n.contains(q, true) || num.filter(Char::isDigit).contains(q.filter(Char::isDigit).ifEmpty { "§" }) }.take(60) }
Column(Modifier.fillMaxSize().padding(pad)) {
TextField(q, { q = it }, Modifier.fillMaxWidth().padding(16.dp).clip(RoundedCornerShape(28.dp)), singleLine = true,
prefix = { Text(stringResource(R.string.to) + " ") }, placeholder = { Text(stringResource(R.string.to_hint)) },
colors = TextFieldDefaults.colors(focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent))
LazyColumn {
if (q.count(Char::isDigit) >= 3) item {
Text(stringResource(R.string.send_to, q), style = WType.body, modifier = Modifier.fillMaxWidth().clickable { onPick(q) }.padding(horizontal = 24.dp, vertical = 14.dp))
}
items(hits) { (name, number) ->
Row(Modifier.fillMaxWidth().clickable { onPick(number) }.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Avatar(name, null, 40.dp); Spacer(Modifier.width(16.dp))
Column { Text(name, style = WType.body); Text(number, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
}
}
}
/** S04 */
@Composable
fun SearchResults(pad: PaddingValues, search: (String) -> List<Message>, nameOf: (String) -> String, onOpen: (Message) -> Unit) {
var q by rememberSaveable { mutableStateOf("") }
val hits = remember(q) { search(q) }
Column(Modifier.fillMaxSize().padding(pad)) {
TextField(q, { q = it }, Modifier.fillMaxWidth().padding(16.dp).clip(RoundedCornerShape(28.dp)), singleLine = true, placeholder = { Text(stringResource(R.string.search_hint)) },
colors = TextFieldDefaults.colors(focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent))
LazyColumn {
itemsIndexed(hits) { _, m ->
Column(Modifier.fillMaxWidth().clickable { onOpen(m) }.padding(horizontal = 24.dp, vertical = 10.dp)) {
Row { Text(nameOf(m.address), style = WType.body, modifier = Modifier.weight(1f)); Text(shortTime(m.date), style = WType.label) }
val i = m.body.indexOf(q, ignoreCase = true)
Text(buildAnnotatedString {
if (i < 0) append(m.body) else {
append(m.body.substring(0, i))
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)) { append(m.body.substring(i, i + q.length)) }
append(m.body.substring(i + q.length))
}
}, style = WType.bodySm, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
@@ -0,0 +1,74 @@
package app.messages.ui.components
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
/** 12-wave scalloped circle for the call screens. */
object CookieShape : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val c = Offset(size.width / 2, size.height / 2); val r = size.minDimension / 2 * .94f
val p = Path()
for (i in 0..180) {
val a = 2 * PI * i / 180
val rr = r * (1 + .05f * cos(12 * a).toFloat())
val x = c.x + rr * cos(a).toFloat(); val y = c.y + rr * sin(a).toFloat()
if (i == 0) p.moveTo(x, y) else p.lineTo(x, y)
}
p.close()
return Outline.Generic(p)
}
}
private val palette = listOf(0xFFD3E3FD, 0xFFC4EED0, 0xFFFFDAD6, 0xFFE9DDFF, 0xFFFFE08B, 0xFFC2E7FF).map { Color(it) }
private val onPalette = listOf(0xFF041E49, 0xFF072711, 0xFF410002, 0xFF21005D, 0xFF241A00, 0xFF001E2F).map { Color(it) }
@Composable
fun Avatar(name: String?, photo: Uri?, size: Dp, cookie: Boolean = false, modifier: Modifier = Modifier) {
val ctx = LocalContext.current
val bmp = produceState<ImageBitmap?>(null, photo) {
value = photo?.let { u -> withContext(Dispatchers.IO) { runCatching { ctx.contentResolver.openInputStream(u)?.use { BitmapFactory.decodeStream(it)?.asImageBitmap() } }.getOrNull() } }
}.value
val shape = if (cookie) CookieShape else CircleShape
val i = abs((name ?: "").hashCode()) % palette.size
Box(modifier.size(size).clip(shape).background(palette[i]), contentAlignment = Alignment.Center) {
when {
bmp != null -> Image(bmp, null, Modifier.size(size), contentScale = ContentScale.Crop)
!name.isNullOrBlank() && name.first().isLetter() -> Text(name.first().uppercase(), color = onPalette[i], fontSize = (size.value * .42f).sp)
else -> Icon(Icons.Rounded.Person, null, tint = onPalette[i], modifier = Modifier.size(size * .55f))
}
}
}
@@ -0,0 +1,27 @@
package app.messages.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
fun MsgTheme(dynamic: Boolean = true, forceDark: Boolean? = null, content: @Composable () -> Unit) {
val dark = forceDark ?: isSystemInDarkTheme()
val ctx = LocalContext.current
val scheme = when {
dynamic && Build.VERSION.SDK_INT >= 31 -> if (dark) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
dark -> darkColorScheme(primary = WColor.darkBubbleOut, onPrimary = WColor.darkOnBubbleOut, background = WColor.darkBg, surface = WColor.darkBg,
surfaceContainerLowest = WColor.darkRow, surfaceContainerHigh = WColor.darkBubbleIn, surfaceContainer = WColor.darkComposer,
primaryContainer = WColor.darkFab, onPrimaryContainer = WColor.darkOnFab, onSurface = WColor.darkText, onSurfaceVariant = WColor.darkTextMuted, error = WColor.darkError)
else -> lightColorScheme(primary = WColor.bubbleOut, onPrimary = WColor.onBubbleOut, background = WColor.bg, surface = WColor.bg,
surfaceContainerLowest = WColor.row, surfaceContainerHigh = WColor.bubbleIn, surfaceContainer = WColor.composer,
primaryContainer = WColor.fab, onPrimaryContainer = WColor.onFab, onSurface = WColor.text, onSurfaceVariant = WColor.textMuted, error = WColor.error)
}
MaterialTheme(colorScheme = scheme, content = content)
}
@@ -0,0 +1,62 @@
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
package app.messages.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(0xFFF8F9FF)
val row = Color(0xFFFFFFFF)
val text = Color(0xFF191C20)
val textMuted = Color(0xFF44474E)
val bubbleIn = Color(0xFFE8EAF1)
val onBubbleIn = Color(0xFF191C20)
val bubbleOut = Color(0xFF2B5EA7)
val onBubbleOut = Color(0xFFFFFFFF)
val unread = Color(0xFF2B5EA7)
val error = Color(0xFFBA1A1A)
val fab = Color(0xFFD6E3FF)
val onFab = Color(0xFF001B3E)
val composer = Color(0xFFECEEF4)
val onComposer = Color(0xFF191C20)
val darkBg = Color(0xFF111318)
val darkRow = Color(0xFF1D2024)
val darkText = Color(0xFFE2E2E9)
val darkTextMuted = Color(0xFFC4C6D0)
val darkBubbleIn = Color(0xFF2B2F36)
val darkOnBubbleIn = Color(0xFFE2E2E9)
val darkBubbleOut = Color(0xFFA8C7FA)
val darkOnBubbleOut = Color(0xFF062E6F)
val darkUnread = Color(0xFFA8C7FA)
val darkError = Color(0xFFFFB4AB)
val darkFab = Color(0xFF284777)
val darkOnFab = Color(0xFFD6E3FF)
val darkComposer = Color(0xFF2B2F36)
val darkOnComposer = 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 = 22.sp, fontWeight = FontWeight.W400)
val title = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
}
object WSpace {
val s0 = 0.dp
val s2 = 2.dp
val s4 = 4.dp
val s8 = 8.dp
val s12 = 12.dp
val s16 = 16.dp
val s24 = 24.dp
}
object WRadius {
val bubble = 20.dp
val bubbleTight = 6.dp
val pill = 999.dp
}
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"><path android:fillColor="#FF0E4F8A" android:pathData="M0,0h108v108h-108z"/></vector>
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"><path android:fillColor="#FFCFE5FF" android:pathData="M36,32h36a8,8 0 0 1 8,8v22a8,8 0 0 1 -8,8h-20l-12,10v-10h-4a8,8 0 0 1 -8,-8v-22a8,8 0 0 1 8,-8z"/><path android:fillColor="#FF0E4F8A" android:pathData="M44,51m-3.2,0a3.2,3.2 0 1,0 6.4,0a3.2,3.2 0 1,0 -6.4,0"/><path android:fillColor="#FF0E4F8A" android:pathData="M54,51m-3.2,0a3.2,3.2 0 1,0 6.4,0a3.2,3.2 0 1,0 -6.4,0"/><path android:fillColor="#FF0E4F8A" android:pathData="M64,51m-3.2,0a3.2,3.2 0 1,0 6.4,0a3.2,3.2 0 1,0 -6.4,0"/></vector>
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"><path android:fillColor="#FF000000" android:pathData="M36,32h36a8,8 0 0 1 8,8v22a8,8 0 0 1 -8,8h-20l-12,10v-10h-4a8,8 0 0 1 -8,-8v-22a8,8 0 0 1 8,-8z"/><path android:fillColor="#FFFFFFFF" android:pathData="M44,51m-3.2,0a3.2,3.2 0 1,0 6.4,0a3.2,3.2 0 1,0 -6.4,0"/><path android:fillColor="#FFFFFFFF" android:pathData="M54,51m-3.2,0a3.2,3.2 0 1,0 6.4,0a3.2,3.2 0 1,0 -6.4,0"/><path android:fillColor="#FFFFFFFF" android:pathData="M64,51m-3.2,0a3.2,3.2 0 1,0 6.4,0a3.2,3.2 0 1,0 -6.4,0"/></vector>
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFFFF" android:pathData="M4,4h16a2,2 0 0 1 2,2v10a2,2 0 0 1 -2,2H8l-4,4V6a2,2 0 0 1 2,-2z"/></vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_title">Nachrichten</string>
<string name="search">Suchen</string>
<string name="search_hint">Nachrichten durchsuchen</string>
<string name="back">Zurück</string>
<string name="archived">Archiviert</string>
<string name="archive">Archivieren</string>
<string name="delete">Löschen</string>
<string name="delete_confirm">%1$d Unterhaltungen löschen?</string>
<string name="copy">Text kopieren</string>
<string name="call">Anrufen</string>
<string name="start_chat">Chat starten</string>
<string name="new_chat">Neuer Chat</string>
<string name="to">An:</string>
<string name="to_hint">Name oder Nummer</string>
<string name="send_to">An %1$s senden</string>
<string name="no_threads">Noch keine Unterhaltungen</string>
<string name="you_prefix">"Du: "</string>
<string name="compose_hint">SMS schreiben</string>
<string name="send">Senden</string>
<string name="sending">Wird gesendet …</string>
<string name="delivered">Zugestellt</string>
<string name="failed_retry">Nicht gesendet, tippen zum Wiederholen</string>
<string name="default_title">Als Standard-SMS-App festlegen</string>
<string name="default_text">Nur die Standard-SMS-App darf Nachrichten empfangen und senden. Du kannst das jederzeit in den Android-Einstellungen ändern.</string>
<string name="need_role_send">Zum Senden muss diese App deine Standard-SMS-App sein.</string>
<string name="make_default">Als Standard festlegen</string>
<string name="ch_messages">Nachrichten</string>
<string name="me">Ich</string>
<string name="reply">Antworten</string>
<string name="reply_hint">Nachricht</string>
<string name="mark_read">Als gelesen markieren</string>
<string name="mms_title">MMS empfangen</string>
<string name="mms_text">Bilder und Gruppennachrichten (MMS) werden noch nicht unterstützt.</string>
</resources>
@@ -0,0 +1,49 @@
package app.messages
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import app.messages.data.Box
import app.messages.data.Message
import app.messages.data.Thread
import app.messages.ui.Conversation
import app.messages.ui.ConversationList
import app.messages.ui.theme.MsgTheme
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Made-up people and texts. */
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [35], qualifiers = "de-rDE-w412dp-h915dp-xxhdpi", application = android.app.Application::class)
class ScreenshotTest {
@get:Rule val compose = createComposeRule()
private val now = System.currentTimeMillis()
private val threads = listOf(
Thread(1, "+4930555012", "Clara Wu", null, "Sehen wir uns morgen am Bahnhof?", now - 120_000, 2, false, false),
Thread(2, "+4930555013", "Ben Okafor", null, "Alles klar, bis später!", now - 3_600_000, 0, true, false),
Thread(3, "+4930555077", "Fahrradladen", null, "Ihr Rad ist abholbereit.", now - 90_000_000, 0, false, false),
Thread(4, "88888", null, null, "Ihr Bestätigungscode lautet 482913", now - 200_000_000, 0, false, false),
)
private val msgs = listOf(
Message(1, 1, "+4930555012", "Hey! Hast du am Wochenende Zeit?", now - 7_200_000, Box.INBOX, true, -1),
Message(2, 1, "+4930555012", "Wir wollten wandern gehen 🥾", now - 7_100_000, Box.INBOX, true, -1),
Message(3, 1, "", "Klingt super, ich bin dabei!", now - 7_000_000, Box.SENT, true, 0),
Message(4, 1, "", "Wann geht es los?", now - 6_900_000, Box.SENT, true, 0),
Message(5, 1, "+4930555012", "Sehen wir uns morgen am Bahnhof?", now - 120_000, Box.INBOX, false, -1),
Message(6, 1, "", "Ja, um 9 Uhr am Gleis 3", now - 60_000, Box.FAILED, true, -1),
)
private fun shot(name: String, dark: Boolean, c: @Composable () -> Unit) {
compose.setContent { MsgTheme(dynamic = false, forceDark = dark) { c() } }
compose.onRoot().captureRoboImage("../../replica/messages/clone-screens/$name.png")
}
@Test fun s01_list() = shot("S01-list-light", false) { ConversationList(threads, PaddingValues(), {}, {}, emptySet()) }
@Test fun s02_chat_dark() = shot("S02-chat-dark", true) { Conversation(msgs, PaddingValues(), {}, {}, {}) }
@Test fun s02_chat_light() = shot("S02-chat-light", false) { Conversation(msgs, PaddingValues(), {}, {}, {}) }
}
+7
View File
@@ -0,0 +1,7 @@
plugins {
id("com.android.application") version "8.13.0" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
id("com.google.devtools.ksp") version "2.2.20-2.0.3" apply false
id("io.github.takahirom.roborazzi") version "1.46.1" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Messages"
include(":app")
+18
View File
@@ -0,0 +1,18 @@
# Architecture: Messages (Android, ohne Google)
Same conventions as the others; **no network permission** (SMS goes through the radio, not the internet).
| Schicht | Wahl | warum |
| --- | --- | --- |
| UI | Compose, M3 Expressive, dynamic colour | same as the others |
| Rolle | `RoleManager.ROLE_SMS` | only the default SMS app may write the SMS provider and receives SMS_DELIVER |
| Empfangen | `SMS_DELIVER` receiver (BROADCAST_SMS), joins multipart PDUs, inserts into `Telephony.Sms.Inbox` | default app duty |
| MMS-Pflicht | `WAP_PUSH_DELIVER` receiver (accepted, stored later with MMS support) | required for the role |
| Senden | `SmsManager.divideMessage` + `sendMultipartTextMessage` with sent/delivered PendingIntents; row in outbox → sent/failed | status updates in the provider |
| Antwort ohne UI | `RESPOND_VIA_MESSAGE` service (SEND_RESPOND_VIA_MESSAGE) | phone app "decline with message"; required for the role |
| Daten | system provider via ContentResolver + ContentObserver | no copies |
| Kontakte | PhoneLookup, same helper as Telefon | |
Data: no own schema (DataStore for settings).
Bites: multipart and encoding, dual SIM (`SubscriptionManager`), status callbacks after process death (receiver updates by message id), notification grouping per thread, short codes and alphanumeric senders (no reply possible).
Build order: role + receive + list → conversation + send + status → notifications with reply → new chat, search, archive, delete → MMS.
+13
View File
@@ -0,0 +1,13 @@
# Build log: Messages
| ID | date | status | missing | notes |
| --- | --- | --- | --- | --- |
| S01 Conversations | 2026-10-17 | done | – | threads from the system SMS provider, unread state, multi-select archive/delete |
| S02 Conversation | 2026-10-17 | done | attachments | grouped bubbles, day separators, sending/delivered/failed with retry |
| S03 New chat | 2026-10-17 | done | – | |
| S04 Search | 2026-10-17 | done | – | |
| S05 Archived | 2026-10-17 | done | – | archive kept in app preferences |
| S06 Settings | – | not started | delivery report toggle | |
| S07 Notification | 2026-10-17 | done | – | MessagingStyle, direct reply, mark as read |
MMS: receiving is acknowledged (needed for the role) but not downloaded yet. **Not tested on a device** (phone disconnected).
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

+62
View File
@@ -0,0 +1,62 @@
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
package app.messages.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(0xFFF8F9FF)
val row = Color(0xFFFFFFFF)
val text = Color(0xFF191C20)
val textMuted = Color(0xFF44474E)
val bubbleIn = Color(0xFFE8EAF1)
val onBubbleIn = Color(0xFF191C20)
val bubbleOut = Color(0xFF2B5EA7)
val onBubbleOut = Color(0xFFFFFFFF)
val unread = Color(0xFF2B5EA7)
val error = Color(0xFFBA1A1A)
val fab = Color(0xFFD6E3FF)
val onFab = Color(0xFF001B3E)
val composer = Color(0xFFECEEF4)
val onComposer = Color(0xFF191C20)
val darkBg = Color(0xFF111318)
val darkRow = Color(0xFF1D2024)
val darkText = Color(0xFFE2E2E9)
val darkTextMuted = Color(0xFFC4C6D0)
val darkBubbleIn = Color(0xFF2B2F36)
val darkOnBubbleIn = Color(0xFFE2E2E9)
val darkBubbleOut = Color(0xFFA8C7FA)
val darkOnBubbleOut = Color(0xFF062E6F)
val darkUnread = Color(0xFFA8C7FA)
val darkError = Color(0xFFFFB4AB)
val darkFab = Color(0xFF284777)
val darkOnFab = Color(0xFFD6E3FF)
val darkComposer = Color(0xFF2B2F36)
val darkOnComposer = 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 = 22.sp, fontWeight = FontWeight.W400)
val title = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
}
object WSpace {
val s0 = 0.dp
val s2 = 2.dp
val s4 = 4.dp
val s8 = 8.dp
val s12 = 12.dp
val s16 = 16.dp
val s24 = 24.dp
}
object WRadius {
val bubble = 20.dp
val bubbleTight = 6.dp
val pill = 999.dp
}
+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.messages.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)
+16
View File
@@ -0,0 +1,16 @@
{
"_about": "Messages fallback palette; dynamic colour on Android 12+.",
"color": {
"bg": "#f8f9ff", "row": "#ffffff", "text": "#191c20", "text-muted": "#44474e",
"bubble-in": "#e8eaf1", "on-bubble-in": "#191c20", "bubble-out": "#2b5ea7", "on-bubble-out": "#ffffff",
"unread": "#2b5ea7", "error": "#ba1a1a", "fab": "#d6e3ff", "on-fab": "#001b3e", "composer": "#eceef4", "on-composer": "#191c20",
"dark-bg": "#111318", "dark-row": "#1d2024", "dark-text": "#e2e2e9", "dark-text-muted": "#c4c6d0",
"dark-bubble-in": "#2b2f36", "dark-on-bubble-in": "#e2e2e9", "dark-bubble-out": "#a8c7fa", "dark-on-bubble-out": "#062e6f",
"dark-unread": "#a8c7fa", "dark-error": "#ffb4ab", "dark-fab": "#284777", "dark-on-fab": "#d6e3ff", "dark-composer": "#2b2f36", "dark-on-composer": "#e2e2e9"
},
"pairs": [["text","bg"],["text","row"],["text-muted","row"],["on-bubble-in","bubble-in"],["on-bubble-out","bubble-out"],["unread","row"],["error","bg"],["on-fab","fab"],["on-composer","composer"],
["dark-text","dark-bg"],["dark-text","dark-row"],["dark-text-muted","dark-row"],["dark-on-bubble-in","dark-bubble-in"],["dark-on-bubble-out","dark-bubble-out"],["dark-unread","dark-row"],["dark-error","dark-bg"],["dark-on-fab","dark-fab"],["dark-on-composer","dark-composer"]],
"type": {"label": {"size": 12, "line": 16, "weight": 500}, "body-sm": {"size": 14, "line": 20, "weight": 400}, "body": {"size": 16, "line": 22, "weight": 400}, "title": {"size": 22, "line": 28, "weight": 400}},
"space": [0, 2, 4, 8, 12, 16, 24],
"radius": {"bubble": 20, "bubble-tight": 6, "pill": 999}
}
+25
View File
@@ -0,0 +1,25 @@
feature,area,priority,original,clone,notes
Become the default SMS app (role),core,must,yes,yes,
Receive SMS and store it in the system provider,core,must,yes,yes,
Send SMS incl. long multipart messages,core,must,yes,yes,
Sent and delivered status; failed with retry,core,must,yes,yes,
Conversation list with unread state,list,must,yes,yes,
Contact names and avatars,list,must,yes,yes,
Conversation bubbles with day separators,chat,must,yes,yes,
Composer with send button,chat,must,yes,yes,
Notification with direct reply and mark as read,notifications,must,yes,yes,
Start a new chat from contacts or a number,compose,must,yes,yes,
Mark as read when opened,core,must,yes,yes,
Delete conversations,list,should,yes,yes,
Archive conversations,list,should,yes,yes,
Search messages,search,should,yes,yes,
Copy message text and links clickable,chat,should,yes,yes,
Call from the conversation,chat,should,yes,yes,
Quick reply from the phone app (respond via message),core,must,yes,yes,needed for the role
Dual SIM choice,core,could,yes,partial,
Dynamic colour and dark mode,design,must,yes,yes,
Delivery reports setting,settings,could,yes,no,
MMS pictures and group MMS,mms,should,yes,no,later
RCS chat features,rcs,could,yes,skip,Google/carrier backend
Spam protection and Gemini features,other,could,yes,skip,Google services
Messages for web,other,could,yes,skip,Google service
1 feature area priority original clone notes
2 Become the default SMS app (role) core must yes yes
3 Receive SMS and store it in the system provider core must yes yes
4 Send SMS incl. long multipart messages core must yes yes
5 Sent and delivered status; failed with retry core must yes yes
6 Conversation list with unread state list must yes yes
7 Contact names and avatars list must yes yes
8 Conversation bubbles with day separators chat must yes yes
9 Composer with send button chat must yes yes
10 Notification with direct reply and mark as read notifications must yes yes
11 Start a new chat from contacts or a number compose must yes yes
12 Mark as read when opened core must yes yes
13 Delete conversations list should yes yes
14 Archive conversations list should yes yes
15 Search messages search should yes yes
16 Copy message text and links clickable chat should yes yes
17 Call from the conversation chat should yes yes
18 Quick reply from the phone app (respond via message) core must yes yes needed for the role
19 Dual SIM choice core could yes partial
20 Dynamic colour and dark mode design must yes yes
21 Delivery reports setting settings could yes no
22 MMS pictures and group MMS mms should yes no later
23 RCS chat features rcs could yes skip Google/carrier backend
24 Spam protection and Gemini features other could yes skip Google services
25 Messages for web other could yes skip Google service
+64
View File
@@ -0,0 +1,64 @@
# Recon map: Google Messages (Android)
Scope: conversation list, conversation with bubbles, compose/new chat, SMS send/receive/delivery, notifications with reply, search, archive/delete, contacts integration
For: personal use, an SMS app without Google services (project NoGooleApps)
Date: 2026-10-17
## Sources
| # | source | URL | notes |
| --- | --- | --- | --- |
| 1 | screenshots | replica/messages/screens/ref-01..05.png | supplied by user: launcher icon, RCS group chat with reactions, list (store art), list on a phone (older), chat with image. Reference only |
| 2 | platform docs | https://developer.android.com/reference/android/provider/Telephony | SMS/MMS provider, default SMS app duties |
| 3 | platform docs | https://developer.android.com/reference/android/app/role/RoleManager#ROLE_SMS | default SMS app role requirements |
## Core loop
See new texts at a glance and answer in two taps, from the list or straight from the notification.
## Screens
| ID | screen | how | purpose | components | states |
| --- | --- | --- | --- | --- | --- |
| S01 | Conversations | launch | all threads | top bar (title, search, menu), rows (avatar, name or number, snippet with "Du: ", time, unread bold + dot), FAB "Chat starten" | empty, no role, filled, unread |
| S02 | Conversation | tap a row | read and reply | top bar (back, avatar, name, call, menu), bubbles (incoming left surface, outgoing right primary), day separators, time + delivery status, composer pill (attach, field, send) | sending, sent, delivered, failed (tap to retry), long text, links |
| S03 | New chat | FAB | pick a recipient | "An:" field, contact suggestions, type a number | empty, matches |
| S04 | Search | search icon | find messages | field, results with highlighted match | |
| S05 | Archived | menu | archived threads | list | empty |
| S06 | Settings | menu | options | default app, notifications (system), delivery reports, theme | |
| S07 | Notification | system | read and reply without opening | MessagingStyle with sender, direct reply, mark as read | |
## Flows
```
F01 Reply to a new text: notification → reply inline → sent (taps: 2)
F02 Read and answer in the app: S01 → S02 → type → send
F03 Start a chat: S01 FAB → S03 pick contact → S02 → send
F04 Delete / archive a thread: long press in S01 → action
F05 Become the default SMS app: first start → system dialog
edge: long messages (multipart), no signal (failed, retry), dual SIM, unknown sender, short codes, delivery report
```
## Components
Avatar (photo, initial, unknown) · ConversationRow · Bubble (in, out; first/middle/last grouping radius) · DaySeparator · Composer pill · Status line (Wird gesendet, Gesendet, Zugestellt, Fehler) · FAB extended "Chat starten" · Selection top bar.
## Data model
Messages and threads live in the system provider (`Telephony.Sms`, `Telephony.Threads`); as default SMS app we write incoming messages there ourselves.
```
Thread (system) thread_id, recipients, snippet, date, read, archived (Telephony.Threads.ARCHIVED) confidence: high
Sms (system) _id, thread_id, address, body, date, type (inbox|sent|outbox|failed|queued), read, status confidence: high
Prefs own: delivery_reports, theme confidence: high
```
## Out of scope
- RCS (typing indicators, reactions, high-res media, read receipts over data): Google's RCS backend (Jibe) and carrier deals; not open to other apps.
- Google account backup, spam protection, Magic Compose/Gemini, "Messages for web": Google services.
- MMS (pictures, group MMS): technically possible for a default SMS app but needs carrier MMS settings and PDU handling; planned after SMS works.
## Size
Screens 7, flows 5. Hard parts: (1) default-SMS duties (store incoming, mark read, respond-via-message service, sent/delivered intents, multipart), (2) fast thread list with contact names, (3) MMS later. Size: M.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+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.
+17
View File
@@ -0,0 +1,17 @@
# Build log: Telefon
App in `telefon/`. Screenshots via Roborazzi with made-up contacts → `replica/telefon/clone-screens/`.
| ID | date | status | missing | notes |
| --- | --- | --- | --- | --- |
| S01 Home | 2026-10-17 | done | delete entries | call log from the system CallLog, merged consecutive calls, favourites = starred contacts |
| S02 Search | 2026-10-17 | done | – | |
| S03 Keypad | 2026-10-17 | done | – | T9 suggestions |
| S04 Voicemail | 2026-10-17 | done | – | dials the SIM's voicemail number |
| S05 Details | 2026-10-17 | done | – | |
| S06 Incoming | 2026-10-17 | done | – | full-screen intent + CallStyle notification; ringtone played by the platform |
| S07 In call | 2026-10-17 | done | add call, merge | mute, speaker, Bluetooth, hold, DTMF, proximity |
| S08 Notifications | 2026-10-17 | done | – | |
| S09 Settings | – | not started | quick reply editing | |
**Not tested on a device yet** (phone was disconnected): role request, a real incoming and outgoing call, lock screen, Bluetooth.
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+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
}
+7
View File
@@ -0,0 +1,7 @@
# 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}
}
+34
View File
@@ -0,0 +1,34 @@
feature,area,priority,original,clone,notes
Become the default phone app (role request),core,must,yes,yes,
Call log grouped by day with direction icons,home,must,yes,yes,
Call back from the log in one tap,home,must,yes,yes,
Missed calls highlighted,home,must,yes,yes,
Filter chips All Missed Contacts,home,should,yes,yes,
Favourites row (starred contacts),home,must,yes,yes,
Search contacts and numbers,search,must,yes,yes,
Keypad with letters,keypad,must,yes,yes,
T9 contact suggestions on the keypad,keypad,should,yes,yes,
Place a call,call,must,yes,yes,
Incoming call full screen with slide answer/decline,call,must,yes,yes,
Incoming call notification with actions,call,must,yes,yes,
In-call screen with timer,call,must,yes,yes,
Mute,call,must,yes,yes,
Speaker,call,must,yes,yes,
In-call keypad (DTMF),call,must,yes,yes,
Hold,call,should,yes,yes,
Second incoming call (call waiting),call,should,yes,partial,the incoming screen appears over the active call; merging/swapping UI missing
Ongoing call notification,call,must,yes,yes,
Proximity sensor screen off at the ear,call,must,yes,yes,
Decline with quick reply SMS,call,should,yes,yes,
Contact and number details with history,details,should,yes,yes,
Toggle favourite,details,could,yes,yes,
Open contact in contacts app / add contact,details,should,yes,yes,
Call voicemail,voicemail,should,yes,yes,
Delete call log entries,home,could,yes,no,
Expressive design (scalloped avatars grouped cards slide pill),design,must,yes,yes,
Dynamic colour and dark mode,design,must,yes,yes,
Bluetooth audio route,call,should,yes,yes,
Spam detection and caller ID,home,could,yes,skip,Google database
Call screening and hold for me,call,could,yes,skip,Google AI
Call recording and live translation,call,could,yes,skip,Google AI; recording blocked for apps
Visual voicemail transcription,voicemail,could,yes,skip,carrier/Google
1 feature area priority original clone notes
2 Become the default phone app (role request) core must yes yes
3 Call log grouped by day with direction icons home must yes yes
4 Call back from the log in one tap home must yes yes
5 Missed calls highlighted home must yes yes
6 Filter chips All Missed Contacts home should yes yes
7 Favourites row (starred contacts) home must yes yes
8 Search contacts and numbers search must yes yes
9 Keypad with letters keypad must yes yes
10 T9 contact suggestions on the keypad keypad should yes yes
11 Place a call call must yes yes
12 Incoming call full screen with slide answer/decline call must yes yes
13 Incoming call notification with actions call must yes yes
14 In-call screen with timer call must yes yes
15 Mute call must yes yes
16 Speaker call must yes yes
17 In-call keypad (DTMF) call must yes yes
18 Hold call should yes yes
19 Second incoming call (call waiting) call should yes partial the incoming screen appears over the active call; merging/swapping UI missing
20 Ongoing call notification call must yes yes
21 Proximity sensor screen off at the ear call must yes yes
22 Decline with quick reply SMS call should yes yes
23 Contact and number details with history details should yes yes
24 Toggle favourite details could yes yes
25 Open contact in contacts app / add contact details should yes yes
26 Call voicemail voicemail should yes yes
27 Delete call log entries home could yes no
28 Expressive design (scalloped avatars grouped cards slide pill) design must yes yes
29 Dynamic colour and dark mode design must yes yes
30 Bluetooth audio route call should yes yes
31 Spam detection and caller ID home could yes skip Google database
32 Call screening and hold for me call could yes skip Google AI
33 Call recording and live translation call could yes skip Google AI; recording blocked for apps
34 Visual voicemail transcription voicemail could yes skip carrier/Google
+98
View File
@@ -0,0 +1,98 @@
# Recon map: Google Phone / Pixel Phone (Android)
Scope: home (search, filter chips, favourites, call log), keypad with contact search, voicemail tab, contact/number details, incoming call, in-call screen, call notifications
For: personal use, a dialer without Google services (project NoGooleApps)
Date: 2026-10-17
## Sources
| # | source | URL | notes |
| --- | --- | --- | --- |
| 1 | screenshots | replica/telefon/screens/ref-01..03.png | supplied by user: home/incoming/in-call (Material 3 Expressive, 2025), older dark in-call with keypad and "More" (hold, video, add call), German call-assist sheet (recording, translation). Reference only |
| 2 | platform docs | https://developer.android.com/reference/android/telecom/InCallService | how a default dialer receives and controls calls |
| 3 | platform docs | https://developer.android.com/reference/android/app/role/RoleManager#ROLE_DIALER | default phone app role |
| 4 | general knowledge as a user | – | filters, favourites, call history grouping, swipe pill answer/decline |
## Core loop
See who called and call back in one tap; answer an incoming call reliably, even on the lock screen.
## Screens
| ID | screen | how to reach | purpose | key components | states seen |
| --- | --- | --- | --- | --- | --- |
| S01 | Home | launch, tab "Start" | favourites + recent calls | search bar with menu, filter chips (Alle, Verpasst, Kontakte), favourites row (round avatars, "Alle ansehen"), call log grouped by day (avatar/initial, name or number, direction icon + type + time, call button), bottom nav | empty, no permission, filled, missed in red (ref-01) |
| S02 | Search | tap the search bar | find contacts and numbers | text field, contact results, "call number" row | empty, results |
| S03 | Keypad | tab "Wähltasten" | dial a number | number field, 12 keys with letters, call FAB, backspace (long = clear), T9 contact suggestions | empty, typing, matches |
| S04 | Voicemail | tab "Mailbox" | reach voicemail | call voicemail button, number from SIM | no number, number set |
| S05 | Contact / number details | tap avatar or row | everything about a person | big avatar, name, numbers with call/SMS, recent calls with that number, favourite star, open in contacts app | contact, unknown number |
| S06 | Incoming call | system, full screen | answer or decline | caller name, number + label, big scalloped avatar, quick actions (Nachricht), slide pill Ablehnen ← ☎ → Annehmen | ringing, locked screen (ref-01) |
| S07 | In call | after answer / outgoing | control the call | status (Wird angerufen…, timer), name, number, avatar, buttons Wähltasten, Stumm, Lautsprecher, Mehr (Halten, Anruf hinzufügen), red hang-up pill; inline DTMF keypad | dialing, active, on hold, ended (ref-01, ref-02) |
| S08 | Call notification | heads-up / ongoing | answer from anywhere, return to call | incoming (Annehmen/Ablehnen), ongoing (Auflegen, timer) | |
| S09 | Settings | menu | options | default app status, quick replies, sounds and vibration (system), blocked numbers (system) | |
## Flows
```
F01 Call back a missed call
S01 → tap call icon on the row → S07
taps: 1
F02 Call a favourite
S01 → tap avatar → S07 (taps: 1)
F03 Dial a number
S03 → digits → call FAB → S07
F04 Answer
S06 slide to "Annehmen" → S07 → hang up
edge: locked phone, second incoming call while in a call, caller hangs up while ringing
F05 Decline with a message
S06 → Nachricht → pick a quick reply → SMS app
F06 Find a contact
S01 search → S02 → tap → S05 → call
F07 Use the keypad during a call (menus, PIN)
S07 → Wähltasten → DTMF
F08 Become the default phone app (first run)
S01 → banner → system role dialog → yes
```
## Components
| component | variants | states | used on |
| --- | --- | --- | --- |
| Search bar | pill with menu + search icon | idle, focused | S01, S02 |
| Filter chip | selected, unselected | | S01 |
| Avatar | photo, initial on colour, unknown (person icon), business (building icon); sizes 48 / 120 / 200 scalloped | | all |
| Call log row | grouped card (large outer, small inner radius), direction icon (incoming, outgoing, missed red), call button | pressed | S01 |
| Dial key | digit + letters | pressed | S03, S07 |
| Call button | green round FAB (keypad), red pill (hang up) | | S03, S07 |
| Slide pill | Ablehnen ← thumb → Annehmen | idle, dragging | S06 |
| Toggle button | round tonal, on = filled | mute, speaker, hold | S07 |
| Bottom nav | Start, Wähltasten, Mailbox | | S01, S03, S04 |
## Inferred data model
The phone app owns almost no data: call history is the system `CallLog`, people are the system `ContactsContract`, voicemail number comes from the SIM.
```
CallEntry (system CallLog) number, name, type (incoming|outgoing|missed|rejected|blocked|voicemail), date, duration, numberLabel
confidence: high
Contact (system Contacts) id, lookupKey, name, photoUri, starred, phones[label, number]
confidence: high
QuickReply own: id, text, position (default set, editable) confidence: high
Settings own: theme confidence: high
```
## Feature matrix
See `features.csv`.
## Out of scope
- Spam detection, caller ID from Google's database, "Non-Spam/Spam" filters: Google service.
- Call screening, hold for me, direct my call, call recording, live translation (ref-03): Google AI services; recording is also blocked for normal apps since Android 10.
- Visual voicemail transcription: carrier/Google service; we only dial voicemail.
- Wi-Fi calling, RTT, video calls: carrier/IMS features the system handles.
- Editing contacts: done by a contacts app; we open it.
## Size
Screens 9, flows 8, entities 2 own. Hard parts: (1) becoming the default dialer and implementing an `InCallService` correctly (call states, audio routing, hold, multiple calls), (2) the incoming call UI over the lock screen with full-screen intent and ringtone handled by the system, (3) fast call log + contacts merging and T9 search. Size: M.
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
local.properties
.idea/
*.iml
.kotlin/
+93
View File
@@ -0,0 +1,93 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp")
id("io.github.takahirom.roborazzi")
}
android {
namespace = "app.telefon"
compileSdk = 36
defaultConfig {
applicationId = "app.telefon"
minSdk = 29
targetSdk = 36
versionCode = 3
versionName = "0.1.2"
}
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
val keyProps = Properties().apply {
val f = file(System.getProperty("user.home") + "/.android/nogoogleapps-release.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
signingConfigs {
if (keyProps.isNotEmpty()) create("release") {
storeFile = file(keyProps.getProperty("storeFile"))
storePassword = keyProps.getProperty("storePassword")
keyAlias = keyProps.getProperty("keyAlias")
keyPassword = keyProps.getProperty("keyPassword")
}
}
buildTypes {
debug {
// installs next to the release app from Obtainium
applicationIdSuffix = ".debug"
resValue("string", "app_name", "Telefon (Debug)")
}
release {
resValue("string", "app_name", "Telefon")
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures { compose = true }
testOptions { unitTests { isIncludeAndroidResources = true } }
}
kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.10.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.11.0")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.4")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4")
implementation("androidx.room:room-runtime:2.7.0")
implementation("androidx.room:room-ktx:2.7.0")
ksp("androidx.room:room-compiler:2.7.0")
implementation("androidx.datastore:datastore-preferences:1.1.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.15.1")
testImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.46.1")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.46.1")
}
roborazzi { outputDir.set(rootProject.file("../replica/telefon/clone-screens")) }
View File
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- no INTERNET: calls go through the system, nothing leaves the device from this app -->
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature android:name="android.hardware.telephony" android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- required for the default phone app role -->
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tel" />
</intent-filter>
</activity>
<activity
android:name=".call.CallActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleInstance"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:taskAffinity="" />
<service
android:name=".call.CallService"
android:exported="true"
android:permission="android.permission.BIND_INCALL_SERVICE">
<meta-data android:name="android.telecom.IN_CALL_SERVICE_UI" android:value="true" />
<intent-filter><action android:name="android.telecom.InCallService" /></intent-filter>
</service>
<receiver android:name=".call.CallActionReceiver" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,205 @@
package app.telefon
import android.Manifest
import android.app.role.RoleManager
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.provider.ContactsContract
import android.telecom.TelecomManager
import android.telephony.TelephonyManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.Dialpad
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Voicemail
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import app.telefon.call.CallService
import app.telefon.data.Contacts
import app.telefon.data.Person
import app.telefon.ui.screens.DetailsScreen
import app.telefon.ui.screens.HomeScreen
import app.telefon.ui.screens.KeypadScreen
import app.telefon.ui.screens.SearchScreen
import app.telefon.ui.screens.VoicemailScreen
import app.telefon.ui.theme.PhoneTheme
import app.telefon.ui.theme.WType
/** Also handles DIAL/VIEW tel: links from other apps (required to be the default phone app). */
class MainActivity : ComponentActivity() {
private val dialNumber = mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
CallService.channels(this)
handle(intent)
setContent { PhoneTheme { PhoneApp(dialNumber.value) { dialNumber.value = null } } }
}
override fun onNewIntent(intent: Intent) { super.onNewIntent(intent); handle(intent) }
private fun handle(i: Intent?) {
if (i?.action == Intent.ACTION_DIAL || i?.action == Intent.ACTION_VIEW) {
dialNumber.value = i.data?.takeIf { it.scheme == "tel" }?.schemeSpecificPart ?: ""
}
}
}
private sealed interface Screen {
data object Search : Screen
data class Details(val person: Person?, val number: String?) : Screen
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PhoneApp(dialRequest: String?, onDialHandled: () -> Unit) {
val ctx = LocalContext.current
var tab by rememberSaveable { mutableIntStateOf(0) }
var screen by remember { mutableStateOf<Screen?>(null) }
var dialInitial by remember { mutableStateOf("") }
LaunchedEffect(dialRequest) { if (dialRequest != null) { dialInitial = dialRequest; tab = 1; screen = null; onDialHandled() } }
var granted by remember { mutableStateOf(hasCore(ctx)) }
val perms = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted = hasCore(ctx) }
val role = ctx.getSystemService(RoleManager::class.java)
var isDefault by remember { mutableStateOf(role.isRoleHeld(RoleManager.ROLE_DIALER)) }
val roleLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
isDefault = role.isRoleHeld(RoleManager.ROLE_DIALER); granted = hasCore(ctx)
}
var askedRole by rememberSaveable { mutableStateOf(false) }
// first start: ask for the role right away; the dialog grants the phone permissions too
LaunchedEffect(Unit) {
if (!isDefault && !askedRole && role.isRoleAvailable(RoleManager.ROLE_DIALER)) {
askedRole = true
roleLauncher.launch(role.createRequestRoleIntent(RoleManager.ROLE_DIALER))
return@LaunchedEffect
}
if (!granted) perms.launch(arrayOf(Manifest.permission.READ_CALL_LOG, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE,
Manifest.permission.READ_PHONE_STATE, Manifest.permission.POST_NOTIFICATIONS))
}
val log by remember(granted) { Contacts.callLog(ctx) }.collectAsState(initial = emptyList())
val people by remember(granted) { Contacts.people(ctx) }.collectAsState(initial = emptyList())
var pendingNumber by remember { mutableStateOf<String?>(null) }
// without the role Android shows the call in the system phone app: say so instead of jumping away
val call: (String) -> Unit = { n -> if (isDefault) placeCall(ctx, n) else pendingNumber = n }
pendingNumber?.let { n ->
androidx.compose.material3.AlertDialog(
onDismissRequest = { pendingNumber = null },
title = { Text(stringResource(R.string.default_title)) },
text = { Text(stringResource(R.string.not_default_call)) },
confirmButton = {
androidx.compose.material3.TextButton(onClick = { pendingNumber = null; roleLauncher.launch(role.createRequestRoleIntent(RoleManager.ROLE_DIALER)) }) {
Text(stringResource(R.string.make_default))
}
},
dismissButton = { androidx.compose.material3.TextButton(onClick = { pendingNumber = null; placeCall(ctx, n) }) { Text(stringResource(R.string.call_anyway)) } },
)
}
screen?.let { s ->
BackHandler { screen = null }
Scaffold(topBar = {
TopAppBar(title = { if (s is Screen.Search) Text(stringResource(R.string.search)) }, navigationIcon = {
IconButton(onClick = { screen = null }) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) }
})
}) { pad ->
when (s) {
Screen.Search -> SearchScreen(people, pad, call) { screen = Screen.Details(it, null) }
is Screen.Details -> {
val p = s.person?.let { sp -> people.firstOrNull { it.id == sp.id } ?: sp }
DetailsScreen(p, s.number, log, pad, call,
onSms = { n -> ctx.startActivity(Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:$n"))) },
onStar = { Contacts.setStarred(ctx, it.id, !it.starred) },
onAddContact = { n -> ctx.startActivity(Intent(Intent.ACTION_INSERT_OR_EDIT).setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE).putExtra(ContactsContract.Intents.Insert.PHONE, n)) },
onOpenContacts = { pp -> ctx.startActivity(Intent(Intent.ACTION_VIEW, ContactsContract.Contacts.getLookupUri(pp.id, pp.lookupKey))) })
}
}
}
return
}
Scaffold(
bottomBar = {
NavigationBar {
listOf(Triple(R.string.tab_home, Icons.Outlined.Home, 0), Triple(R.string.tab_keypad, Icons.Outlined.Dialpad, 1), Triple(R.string.tab_voicemail, Icons.Outlined.Voicemail, 2)).forEach { (t, ic, i) ->
NavigationBarItem(tab == i, { tab = i }, { Icon(ic, null) }, label = { Text(stringResource(t)) })
}
}
},
) { pad ->
Column {
if (!isDefault) {
Column(
Modifier.padding(start = 16.dp, end = 16.dp, top = pad.calculateTopPadding() + 8.dp).clip(RoundedCornerShape(24.dp))
.background(MaterialTheme.colorScheme.secondaryContainer)
.clickable { roleLauncher.launch(role.createRequestRoleIntent(RoleManager.ROLE_DIALER)) }.padding(16.dp),
) {
Text(stringResource(R.string.default_title), style = WType.body, color = MaterialTheme.colorScheme.onSecondaryContainer)
Text(stringResource(R.string.default_text), style = WType.bodySm, color = MaterialTheme.colorScheme.onSecondaryContainer)
}
}
val inner = if (isDefault) pad else androidx.compose.foundation.layout.PaddingValues(bottom = pad.calculateBottomPadding())
when (tab) {
0 -> HomeScreen(log, people, inner, call, onOpenNumber = { n ->
screen = Screen.Details(people.firstOrNull { p -> p.phones.any { android.telephony.PhoneNumberUtils.compare(it.number, n) } }, n)
}, onOpenPerson = { screen = Screen.Details(it, null) }, onSearch = { screen = Screen.Search })
1 -> KeypadScreen(people, inner, dialInitial, call)
2 -> VoicemailScreen(voicemailNumber(ctx), inner, call)
}
}
}
}
private fun hasCore(ctx: android.content.Context) = listOf(Manifest.permission.READ_CALL_LOG, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE)
.all { ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED }
/** Through Telecom, so SIM choice and emergency numbers are handled by the system. */
private fun placeCall(ctx: android.content.Context, number: String) {
val uri = Uri.fromParts("tel", number, null)
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) {
runCatching { ctx.getSystemService(TelecomManager::class.java).placeCall(uri, Bundle()) }
.onFailure { ctx.startActivity(Intent(Intent.ACTION_DIAL, uri)) }
} else ctx.startActivity(Intent(Intent.ACTION_DIAL, uri))
}
@android.annotation.SuppressLint("MissingPermission")
private fun voicemailNumber(ctx: android.content.Context): String? = runCatching {
ctx.getSystemService(TelephonyManager::class.java).voiceMailNumber?.takeIf { it.isNotBlank() }
}.getOrNull()
@@ -0,0 +1,248 @@
package app.telefon.call
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.telecom.CallAudioState
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.VolumeUp
import androidx.compose.material.icons.rounded.Bluetooth
import androidx.compose.material.icons.rounded.CallEnd
import androidx.compose.material.icons.rounded.Dialpad
import androidx.compose.material.icons.rounded.MicOff
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.Phone
import androidx.compose.material.icons.outlined.Sms
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import app.telefon.R
import app.telefon.data.Contacts
import app.telefon.ui.components.Avatar
import app.telefon.ui.components.Fmt
import app.telefon.ui.screens.DialPad
import app.telefon.ui.theme.LocalCallColors
import app.telefon.ui.theme.PhoneTheme
import app.telefon.ui.theme.WType
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** Incoming and in-call screen; shown over the lock screen. */
class CallActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setShowWhenLocked(true)
setTurnScreenOn(true)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
enableEdgeToEdge()
setContent {
val calls by CallManager.state.collectAsState()
val audio by CallManager.audio.collectAsState()
LaunchedEffect(calls) { if (calls.isEmpty() || calls.all { it.phase == Phase.DISCONNECTED }) { delay(800); if (CallManager.state.value.none { it.phase != Phase.DISCONNECTED }) finish() } }
PhoneTheme {
val ringing = calls.firstOrNull { it.phase == Phase.RINGING }
val main = calls.firstOrNull { it.phase != Phase.RINGING } ?: ringing
when {
main == null -> Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface))
// a second call while talking: answer replaces holding the first, which the system does for us
ringing != null -> IncomingScreen(ringing, onAnswer = { CallManager.answer(ringing.id) }, onDecline = { CallManager.reject(ringing.id) },
onMessage = { text -> CallManager.reject(ringing.id, text) })
else -> InCallScreen(main, audio, calls.count { it.phase != Phase.DISCONNECTED })
}
}
}
}
companion object {
fun intent(context: Context) = Intent(context, CallActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
fun pending(context: Context): PendingIntent = PendingIntent.getActivity(context, 0, intent(context), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
}
@Composable
private fun CallerHeader(call: CallUi, status: String, avatarSize: Int) {
val ctx = LocalContext.current
val who = remember(call.number) { Contacts.lookup(ctx, call.number) }
Column(Modifier.fillMaxWidth().statusBarsPadding().padding(top = 48.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(status, style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(8.dp))
Text(who?.first ?: Fmt.number(call.number).ifEmpty { stringResource(R.string.unknown) }, style = WType.caller, color = MaterialTheme.colorScheme.onSurface)
if (who != null) Text(Fmt.number(call.number), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(32.dp))
Avatar(who?.first, who?.second, avatarSize.dp, cookie = true)
}
}
@Composable
fun IncomingScreen(call: CallUi, onAnswer: () -> Unit, onDecline: () -> Unit, onMessage: (String) -> Unit) {
var replies by remember { mutableStateOf(false) }
val quick = listOf(R.string.reply_1, R.string.reply_2, R.string.reply_3).map { stringResource(it) }
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).navigationBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally) {
CallerHeader(call, stringResource(R.string.incoming_call), 200)
Spacer(Modifier.weight(1f))
Box {
Row(Modifier.clip(RoundedCornerShape(50)).background(MaterialTheme.colorScheme.surfaceContainerLowest).clickable { replies = true }.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Outlined.Sms, null, Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.message), style = WType.bodySm)
}
DropdownMenu(replies, { replies = false }) { quick.forEach { q -> DropdownMenuItem(text = { Text(q) }, onClick = { replies = false; onMessage(q) }) } }
}
Spacer(Modifier.height(32.dp))
SlideToAnswer(onAnswer, onDecline)
Spacer(Modifier.height(40.dp))
}
}
/** Ablehnen ← ☎ → Annehmen */
@Composable
fun SlideToAnswer(onAnswer: () -> Unit, onDecline: () -> Unit) {
val scope = rememberCoroutineScope()
val x = remember { Animatable(0f) }
val decline = stringResource(R.string.decline); val answer = stringResource(R.string.answer)
BoxWithConstraints(
Modifier.padding(horizontal = 24.dp).fillMaxWidth().height(76.dp).clip(RoundedCornerShape(38.dp)).background(MaterialTheme.colorScheme.surfaceContainer)
.semantics { customActions = listOf(CustomAccessibilityAction(answer) { onAnswer(); true }, CustomAccessibilityAction(decline) { onDecline(); true }) },
) {
val max = with(LocalDensity.current) { (maxWidth / 2 - 44.dp).toPx() }
Row(Modifier.fillMaxSize().padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(decline, style = WType.body, modifier = Modifier.clickable(onClick = onDecline))
Text(answer, style = WType.body, modifier = Modifier.clickable(onClick = onAnswer))
}
Box(
Modifier.align(Alignment.Center).offset { IntOffset(x.value.roundToInt(), 0) }.size(88.dp, 64.dp).clip(RoundedCornerShape(32.dp))
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
.pointerInput(max) {
detectHorizontalDragGestures(onDragEnd = {
scope.launch {
when { x.value > max * .5f -> { x.snapTo(max); onAnswer() }; x.value < -max * .5f -> { x.snapTo(-max); onDecline() }; else -> x.animateTo(0f, spring(.6f)) }
}
}) { _, d -> scope.launch { x.snapTo((x.value + d).coerceIn(-max, max)) } }
},
contentAlignment = Alignment.Center,
) { Icon(Icons.Rounded.Phone, null, tint = LocalCallColors.current.accept) }
}
}
@Composable
fun InCallScreen(call: CallUi, audio: AudioUi, count: Int, nowOverride: Long? = null) {
val now = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
if (nowOverride == null) LaunchedEffect(Unit) { while (true) { now.longValue = System.currentTimeMillis(); delay(1000) } }
var keypad by remember { mutableStateOf(false) }
var more by remember { mutableStateOf(false) }
var dtmf by remember { mutableStateOf("") }
val status = when (call.phase) {
Phase.DIALING -> stringResource(R.string.calling)
Phase.HOLDING -> stringResource(R.string.on_hold)
Phase.DISCONNECTED -> stringResource(R.string.call_ended)
else -> call.connectedAt?.let { Fmt.duration(now.longValue - it) } ?: ""
}
val cc = LocalCallColors.current
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).navigationBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally) {
if (keypad) {
Column(Modifier.statusBarsPadding().padding(top = 24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(dtmf.ifEmpty { " " }, style = WType.dial)
Text(status, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.weight(1f))
DialPad { c -> dtmf += c; CallManager.dtmf(call.id, c) }
} else {
CallerHeader(call, status, 140)
Spacer(Modifier.weight(1f))
}
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp)).background(MaterialTheme.colorScheme.surfaceContainerLow).padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
Toggle(Icons.Rounded.Dialpad, stringResource(R.string.keypad), keypad) { keypad = !keypad }
Toggle(Icons.Rounded.MicOff, stringResource(R.string.mute), audio.muted) { CallManager.setMuted(!audio.muted) }
val bt = audio.supported and CallAudioState.ROUTE_BLUETOOTH != 0
val speaker = audio.route == CallAudioState.ROUTE_SPEAKER
Toggle(Icons.AutoMirrored.Rounded.VolumeUp, stringResource(R.string.speaker), speaker) {
CallManager.setRoute(if (speaker) CallAudioState.ROUTE_WIRED_OR_EARPIECE else CallAudioState.ROUTE_SPEAKER)
}
Box {
Toggle(Icons.Rounded.MoreVert, stringResource(R.string.more), more) { more = true }
DropdownMenu(more, { more = false }) {
if (call.canHold) DropdownMenuItem(
text = { Text(stringResource(if (call.phase == Phase.HOLDING) R.string.resume else R.string.hold)) },
leadingIcon = { Icon(Icons.Rounded.Pause, null) },
onClick = { more = false; CallManager.hold(call.id, call.phase != Phase.HOLDING) },
)
if (bt) DropdownMenuItem(text = { Text(stringResource(R.string.bluetooth)) }, leadingIcon = { Icon(Icons.Rounded.Bluetooth, null) },
onClick = { more = false; CallManager.setRoute(CallAudioState.ROUTE_BLUETOOTH) })
}
}
}
Spacer(Modifier.height(24.dp))
Box(Modifier.size(160.dp, 64.dp).clip(RoundedCornerShape(32.dp)).background(cc.hangup).clickable { CallManager.hangUp(call.id) }, contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.CallEnd, stringResource(R.string.hang_up), tint = cc.onHangup, modifier = Modifier.size(32.dp))
}
}
}
}
@Composable
private fun Toggle(icon: ImageVector, label: String, on: Boolean, onClick: () -> Unit) {
val s = MaterialTheme.colorScheme
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(76.dp)) {
Box(Modifier.size(64.dp).clip(CircleShape).background(if (on) s.primary else s.surfaceContainerLowest).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
Icon(icon, label, tint = if (on) s.onPrimary else s.onSurface)
}
Spacer(Modifier.height(6.dp))
Text(label, style = WType.label, color = s.onSurfaceVariant, maxLines = 1)
}
}
@@ -0,0 +1,78 @@
package app.telefon.call
import android.telecom.Call
import android.telecom.CallAudioState
import android.telecom.VideoProfile
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
enum class Phase { RINGING, DIALING, ACTIVE, HOLDING, DISCONNECTED }
data class CallUi(
val id: String,
val number: String,
val phase: Phase,
/** System.currentTimeMillis() when it became active, for the timer */
val connectedAt: Long?,
val canHold: Boolean,
)
data class AudioUi(val muted: Boolean = false, val route: Int = CallAudioState.ROUTE_EARPIECE, val supported: Int = 0)
/**
* The calls the system gives our InCallService, as UI state. All commands go through here,
* so the notification, the call screen and the service never disagree.
*/
object CallManager {
private val calls = mutableListOf<Call>()
private val _state = MutableStateFlow<List<CallUi>>(emptyList())
val state: StateFlow<List<CallUi>> = _state
private val _audio = MutableStateFlow(AudioUi())
val audio: StateFlow<AudioUi> = _audio
internal var service: CallService? = null
private val connectedAt = mutableMapOf<Call, Long>()
private val callback = object : Call.Callback() {
override fun onStateChanged(call: Call, state: Int) = publish()
override fun onDetailsChanged(call: Call, details: Call.Details) = publish()
}
internal fun added(call: Call) { calls += call; call.registerCallback(callback); publish() }
internal fun removed(call: Call) { call.unregisterCallback(callback); calls -= call; connectedAt.remove(call); publish() }
internal fun audioChanged(a: CallAudioState) { _audio.value = AudioUi(a.isMuted, a.route, a.supportedRouteMask) }
private fun key(c: Call) = Integer.toHexString(System.identityHashCode(c))
private fun find(id: String) = calls.firstOrNull { key(it) == id }
private fun phaseOf(c: Call) = when (c.details.state) {
Call.STATE_RINGING -> Phase.RINGING
Call.STATE_DIALING, Call.STATE_CONNECTING, Call.STATE_SELECT_PHONE_ACCOUNT, Call.STATE_PULLING_CALL -> Phase.DIALING
Call.STATE_ACTIVE -> Phase.ACTIVE
Call.STATE_HOLDING -> Phase.HOLDING
else -> Phase.DISCONNECTED
}
private fun publish() {
_state.value = calls.map { c ->
val phase = phaseOf(c)
if (phase == Phase.ACTIVE && c !in connectedAt) connectedAt[c] = c.details.connectTimeMillis.takeIf { it > 0 } ?: System.currentTimeMillis()
CallUi(
id = key(c),
number = c.details.handle?.schemeSpecificPart.orEmpty(),
phase = phase,
connectedAt = connectedAt[c],
canHold = c.details.can(Call.Details.CAPABILITY_HOLD),
)
}
service?.onCallsChanged(_state.value)
}
fun answer(id: String) = find(id)?.answer(VideoProfile.STATE_AUDIO_ONLY)
fun reject(id: String, message: String? = null) = find(id)?.reject(message != null, message)
fun hangUp(id: String) = find(id)?.disconnect()
fun hold(id: String, on: Boolean) = find(id)?.let { if (on) it.hold() else it.unhold() }
fun dtmf(id: String, c: Char) = find(id)?.let { it.playDtmfTone(c); it.stopDtmfTone() }
fun setMuted(m: Boolean) = service?.setMuted(m)
@Suppress("DEPRECATION")
fun setRoute(route: Int) = service?.setAudioRoute(route)
}
@@ -0,0 +1,125 @@
package app.telefon.call
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.PowerManager
import android.telecom.Call
import android.telecom.CallAudioState
import android.telecom.InCallService
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import app.telefon.R
import app.telefon.data.Contacts
/** Bound by the system while calls exist, once this app is the default phone app. */
class CallService : InCallService() {
private var proximity: PowerManager.WakeLock? = null
override fun onCreate() { super.onCreate(); CallManager.service = this; channels(this) }
override fun onDestroy() { CallManager.service = null; releaseProximity(); super.onDestroy() }
override fun onCallAdded(call: Call) {
CallManager.added(call)
// a crash here would make Telecom switch to the system phone app, so failures are logged, not thrown
runCatching { if (call.details.state == Call.STATE_RINGING) showIncoming(call) else startActivity(CallActivity.intent(this)) }
.onFailure { android.util.Log.e("Telefon", "onCallAdded", it); runCatching { startActivity(CallActivity.intent(this)) } }
}
override fun onCallRemoved(call: Call) = CallManager.removed(call)
@Deprecated("CallEndpoint API on 34+, the audio state still works")
override fun onCallAudioStateChanged(audioState: CallAudioState) {
CallManager.audioChanged(audioState)
updateProximity(audioState.route)
}
internal fun onCallsChanged(calls: List<CallUi>) = runCatching { updateNotification(calls) }.onFailure { android.util.Log.e("Telefon", "notification", it) }
private fun updateNotification(calls: List<CallUi>) {
val nm = getSystemService(NotificationManager::class.java)
val ringing = calls.firstOrNull { it.phase == Phase.RINGING }
val current = calls.firstOrNull { it.phase != Phase.RINGING && it.phase != Phase.DISCONNECTED }
when {
ringing != null -> Unit // incoming notification already shown
current != null -> nm.notify(ID, ongoing(current))
else -> { nm.cancel(ID); releaseProximity() }
}
if (ringing == null && current == null) nm.cancel(ID)
}
private fun showIncoming(call: Call) {
val number = call.details.handle?.schemeSpecificPart.orEmpty()
val name = Contacts.lookupName(this, number) ?: number.ifEmpty { getString(R.string.unknown) }
val full = CallActivity.pending(this)
val person = Person.Builder().setName(name).setImportant(true).build()
val n = NotificationCompat.Builder(this, CH_INCOMING).setSmallIcon(R.drawable.ic_stat_call)
.setContentTitle(name).setContentText(getString(R.string.incoming_call))
.setCategory(NotificationCompat.CATEGORY_CALL).setPriority(NotificationCompat.PRIORITY_MAX)
.setOngoing(true).setFullScreenIntent(full, true).setContentIntent(full)
.setStyle(NotificationCompat.CallStyle.forIncomingCall(person, action(ACTION_DECLINE), action(ACTION_ANSWER)))
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC).build()
getSystemService(NotificationManager::class.java).notify(ID, n)
}
private fun ongoing(c: CallUi): Notification {
val name = Contacts.lookupName(this, c.number) ?: c.number
// not CallStyle: that style is only allowed with a foreground service or a full-screen intent
// and throws otherwise, which made Telecom fall back to the system phone app
return NotificationCompat.Builder(this, CH_ONGOING).setSmallIcon(R.drawable.ic_stat_call)
.setContentTitle(name).setContentText(getString(R.string.ongoing_call)).setContentIntent(CallActivity.pending(this)).setOngoing(true).setSilent(true)
.addAction(0, getString(R.string.hang_up), action(ACTION_HANGUP))
.apply { c.connectedAt?.let { setUsesChronometer(true).setWhen(it).setShowWhen(true) } }
.setCategory(NotificationCompat.CATEGORY_CALL).build()
}
private fun action(a: String) = PendingIntent.getBroadcast(
this, a.hashCode(), Intent(this, CallActionReceiver::class.java).setAction(a), PendingIntent.FLAG_IMMUTABLE,
)
/** Screen off when the phone is at the ear, only on the earpiece. */
@SuppressLint("WakelockTimeout")
private fun updateProximity(route: Int) {
val inCall = CallManager.state.value.any { it.phase == Phase.ACTIVE || it.phase == Phase.DIALING }
if (route == CallAudioState.ROUTE_EARPIECE && inCall) {
if (proximity == null) proximity = getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "telefon:proximity").also { it.acquire() }
} else releaseProximity()
}
private fun releaseProximity() { proximity?.takeIf { it.isHeld }?.release(); proximity = null }
companion object {
const val ID = 1
const val CH_INCOMING = "incoming"
const val CH_ONGOING = "ongoing"
const val ACTION_ANSWER = "app.telefon.ANSWER"
const val ACTION_DECLINE = "app.telefon.DECLINE"
const val ACTION_HANGUP = "app.telefon.HANGUP"
fun channels(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannels(listOf(
// the platform plays the ringtone for the default dialer; our channel stays silent
NotificationChannel(CH_INCOMING, context.getString(R.string.ch_incoming), NotificationManager.IMPORTANCE_HIGH).apply { setSound(null, null) },
NotificationChannel(CH_ONGOING, context.getString(R.string.ch_ongoing), NotificationManager.IMPORTANCE_LOW),
))
}
}
}
class CallActionReceiver : android.content.BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val ringing = CallManager.state.value.firstOrNull { it.phase == Phase.RINGING }
val current = CallManager.state.value.firstOrNull { it.phase != Phase.RINGING }
when (intent.action) {
CallService.ACTION_ANSWER -> ringing?.let { CallManager.answer(it.id); context.startActivity(CallActivity.intent(context)) }
CallService.ACTION_DECLINE -> ringing?.let { CallManager.reject(it.id) }
CallService.ACTION_HANGUP -> current?.let { CallManager.hangUp(it.id) }
}
}
}
@@ -0,0 +1,159 @@
package app.telefon.data
import android.Manifest
import android.content.ContentResolver
import android.content.Context
import android.content.pm.PackageManager
import android.database.ContentObserver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.CallLog
import android.provider.ContactsContract
import android.telephony.PhoneNumberUtils
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import java.util.Locale
enum class Direction { INCOMING, OUTGOING, MISSED, REJECTED, BLOCKED, VOICEMAIL }
data class CallEntry(
val id: Long, val number: String, val name: String?, val photo: Uri?, val direction: Direction,
val date: Long, val durationS: Long, val label: String?, val count: Int = 1,
)
data class Person(val id: Long, val lookupKey: String, val name: String, val photo: Uri?, val starred: Boolean, val phones: List<Phone>)
data class Phone(val number: String, val label: String)
private fun Context.has(p: String) = ContextCompat.checkSelfPermission(this, p) == PackageManager.PERMISSION_GRANTED
/** Emits again whenever the given content changes. */
private fun <T> ContentResolver.observe(uri: Uri, load: () -> T): Flow<T> = callbackFlow {
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) { trySend(load()) }
}
registerContentObserver(uri, true, obs)
trySend(load())
awaitClose { unregisterContentObserver(obs) }
}.flowOn(Dispatchers.IO)
object Contacts {
fun lookupName(context: Context, number: String): String? = lookup(context, number)?.first
/** name and photo for a number (PhoneLookup handles formatting differences) */
fun lookup(context: Context, number: String): Pair<String, Uri?>? {
if (number.isBlank() || !context.has(Manifest.permission.READ_CONTACTS)) return null
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
return runCatching {
context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME, ContactsContract.PhoneLookup.PHOTO_URI), null, null, null)?.use { c ->
if (c.moveToFirst()) c.getString(0) to c.getString(1)?.let(Uri::parse) else null
}
}.getOrNull()
}
fun callLog(context: Context): Flow<List<CallEntry>> = context.contentResolver.observe(CallLog.Calls.CONTENT_URI) { loadLog(context) }
private fun loadLog(context: Context): List<CallEntry> {
if (!context.has(Manifest.permission.READ_CALL_LOG)) return emptyList()
val out = mutableListOf<CallEntry>()
val cols = arrayOf(CallLog.Calls._ID, CallLog.Calls.NUMBER, CallLog.Calls.CACHED_NAME, CallLog.Calls.CACHED_PHOTO_URI,
CallLog.Calls.TYPE, CallLog.Calls.DATE, CallLog.Calls.DURATION, CallLog.Calls.CACHED_NUMBER_LABEL, CallLog.Calls.CACHED_NUMBER_TYPE)
runCatching {
context.contentResolver.query(CallLog.Calls.CONTENT_URI, cols, null, null, "${CallLog.Calls.DATE} DESC LIMIT 300")?.use { c ->
while (c.moveToNext()) {
val type = c.getInt(4)
val labelType = c.getInt(8)
val label = c.getString(7) ?: if (labelType != 0) ContactsContract.CommonDataKinds.Phone.getTypeLabel(context.resources, labelType, "").toString() else null
out += CallEntry(
id = c.getLong(0), number = c.getString(1).orEmpty(), name = c.getString(2)?.takeIf { it.isNotBlank() },
photo = c.getString(3)?.let(Uri::parse),
direction = when (type) {
CallLog.Calls.OUTGOING_TYPE -> Direction.OUTGOING
CallLog.Calls.MISSED_TYPE -> Direction.MISSED
CallLog.Calls.REJECTED_TYPE -> Direction.REJECTED
CallLog.Calls.BLOCKED_TYPE -> Direction.BLOCKED
CallLog.Calls.VOICEMAIL_TYPE -> Direction.VOICEMAIL
else -> Direction.INCOMING
},
date = c.getLong(5), durationS = c.getLong(6), label = label,
)
}
}
}
// consecutive calls with the same number and direction collapse into one row with a count
return out.fold(mutableListOf<CallEntry>()) { acc, e ->
val last = acc.lastOrNull()
if (last != null && same(last.number, e.number) && last.direction == e.direction && sameDay(last.date, e.date)) acc[acc.lastIndex] = last.copy(count = last.count + 1)
else acc += e
acc
}
}
private fun same(a: String, b: String) = PhoneNumberUtils.areSamePhoneNumber(a, b, Locale.getDefault().country.ifEmpty { "DE" })
private fun sameDay(a: Long, b: Long) = a / 86_400_000 == b / 86_400_000
fun people(context: Context): Flow<List<Person>> = context.contentResolver.observe(ContactsContract.Contacts.CONTENT_URI) { loadPeople(context) }
private fun loadPeople(context: Context): List<Person> {
if (!context.has(Manifest.permission.READ_CONTACTS)) return emptyList()
val phones = mutableMapOf<Long, MutableList<Phone>>()
runCatching {
context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
arrayOf(ContactsContract.CommonDataKinds.Phone.CONTACT_ID, ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.LABEL),
null, null, null,
)?.use { c ->
while (c.moveToNext()) {
val label = ContactsContract.CommonDataKinds.Phone.getTypeLabel(context.resources, c.getInt(2), c.getString(3) ?: "").toString()
phones.getOrPut(c.getLong(0)) { mutableListOf() }.let { list -> val n = c.getString(1).orEmpty(); if (list.none { same(it.number, n) }) list += Phone(n, label) }
}
}
}
val out = mutableListOf<Person>()
runCatching {
context.contentResolver.query(
ContactsContract.Contacts.CONTENT_URI,
arrayOf(ContactsContract.Contacts._ID, ContactsContract.Contacts.LOOKUP_KEY, ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, ContactsContract.Contacts.PHOTO_URI, ContactsContract.Contacts.STARRED),
"${ContactsContract.Contacts.HAS_PHONE_NUMBER} = 1", null, "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} COLLATE LOCALIZED ASC",
)?.use { c ->
while (c.moveToNext()) {
val id = c.getLong(0)
out += Person(id, c.getString(1).orEmpty(), c.getString(2).orEmpty(), c.getString(3)?.let(Uri::parse), c.getInt(4) == 1, phones[id].orEmpty())
}
}
}
return out
}
fun setStarred(context: Context, id: Long, starred: Boolean) = runCatching {
context.contentResolver.update(
ContactsContract.Contacts.CONTENT_URI, android.content.ContentValues().apply { put(ContactsContract.Contacts.STARRED, if (starred) 1 else 0) },
"${ContactsContract.Contacts._ID} = ?", arrayOf(id.toString()),
)
}
fun deleteLogEntries(context: Context, number: String) = runCatching {
context.contentResolver.delete(CallLog.Calls.CONTENT_URI, "${CallLog.Calls.NUMBER} = ?", arrayOf(number))
}
}
/** T9: 2=abc, 3=def … Matches the digits against name word starts and against the number. */
object T9 {
private val map = mapOf('a' to '2', 'b' to '2', 'c' to '2', 'ä' to '2', 'd' to '3', 'e' to '3', 'f' to '3', 'g' to '4', 'h' to '4', 'i' to '4',
'j' to '5', 'k' to '5', 'l' to '5', 'm' to '6', 'n' to '6', 'o' to '6', 'ö' to '6', 'p' to '7', 'q' to '7', 'r' to '7', 's' to '7', 'ß' to '7',
't' to '8', 'u' to '8', 'v' to '8', 'ü' to '8', 'w' to '9', 'x' to '9', 'y' to '9', 'z' to '9')
fun digits(name: String) = name.lowercase().map { map[it] ?: if (it.isDigit()) it else ' ' }.joinToString("")
fun matches(p: Person, input: String): Boolean {
if (input.isEmpty()) return false
val words = digits(p.name).split(' ').filter { it.isNotEmpty() }
val joined = words.joinToString("")
return words.any { it.startsWith(input) } || joined.startsWith(input) ||
p.phones.any { it.number.filter(Char::isDigit).contains(input) }
}
}
@@ -0,0 +1,74 @@
package app.telefon.ui.components
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
/** 12-wave scalloped circle for the call screens. */
object CookieShape : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val c = Offset(size.width / 2, size.height / 2); val r = size.minDimension / 2 * .94f
val p = Path()
for (i in 0..180) {
val a = 2 * PI * i / 180
val rr = r * (1 + .05f * cos(12 * a).toFloat())
val x = c.x + rr * cos(a).toFloat(); val y = c.y + rr * sin(a).toFloat()
if (i == 0) p.moveTo(x, y) else p.lineTo(x, y)
}
p.close()
return Outline.Generic(p)
}
}
private val palette = listOf(0xFFD3E3FD, 0xFFC4EED0, 0xFFFFDAD6, 0xFFE9DDFF, 0xFFFFE08B, 0xFFC2E7FF).map { Color(it) }
private val onPalette = listOf(0xFF041E49, 0xFF072711, 0xFF410002, 0xFF21005D, 0xFF241A00, 0xFF001E2F).map { Color(it) }
@Composable
fun Avatar(name: String?, photo: Uri?, size: Dp, cookie: Boolean = false, modifier: Modifier = Modifier) {
val ctx = LocalContext.current
val bmp = produceState<ImageBitmap?>(null, photo) {
value = photo?.let { u -> withContext(Dispatchers.IO) { runCatching { ctx.contentResolver.openInputStream(u)?.use { BitmapFactory.decodeStream(it)?.asImageBitmap() } }.getOrNull() } }
}.value
val shape = if (cookie) CookieShape else CircleShape
val i = abs((name ?: "").hashCode()) % palette.size
Box(modifier.size(size).clip(shape).background(palette[i]), contentAlignment = Alignment.Center) {
when {
bmp != null -> Image(bmp, null, Modifier.size(size), contentScale = ContentScale.Crop)
!name.isNullOrBlank() && name.first().isLetter() -> Text(name.first().uppercase(), color = onPalette[i], fontSize = (size.value * .42f).sp)
else -> Icon(Icons.Rounded.Person, null, tint = onPalette[i], modifier = Modifier.size(size * .55f))
}
}
}
@@ -0,0 +1,16 @@
package app.telefon.ui.components
import android.telephony.PhoneNumberUtils
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
object Fmt {
fun number(n: String): String = if (n.isBlank()) "" else PhoneNumberUtils.formatNumber(n, Locale.getDefault().country.ifEmpty { "DE" }) ?: n
fun duration(ms: Long): String { val s = ms / 1000; return if (s >= 3600) "%d:%02d:%02d".format(s / 3600, s / 60 % 60, s % 60) else "%d:%02d".format(s / 60, s % 60) }
fun time(at: Long): String = Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault()).toLocalTime().format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT))
fun day(at: Long): LocalDate = Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault()).toLocalDate()
}
@@ -0,0 +1,161 @@
package app.telefon.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.telefon.ui.theme.WSpace
import app.telefon.ui.theme.WType
/*
* Settings in the style of current Pixel system settings: grouped tiles with large outer
* corners and small inner ones, 2dp gaps, tonal icon circles, values as summaries,
* choices in dialogs.
*/
private val Outer = 24.dp
private val Inner = 4.dp
private fun tileShape(index: Int, count: Int): Shape = when {
count == 1 -> RoundedCornerShape(Outer)
index == 0 -> RoundedCornerShape(topStart = Outer, topEnd = Outer, bottomStart = Inner, bottomEnd = Inner)
index == count - 1 -> RoundedCornerShape(topStart = Inner, topEnd = Inner, bottomStart = Outer, bottomEnd = Outer)
else -> RoundedCornerShape(Inner)
}
class GroupScope internal constructor() {
internal val items = mutableListOf<@Composable (Shape) -> Unit>()
fun item(content: @Composable (Shape) -> Unit) { items += content }
}
@Composable
fun SettingsGroup(title: String? = null, modifier: Modifier = Modifier, content: GroupScope.() -> Unit) {
val scope = GroupScope().apply(content)
Column(modifier.padding(horizontal = WSpace.s16)) {
if (title != null) {
Text(
title, style = WType.label.copy(fontSize = WType.bodySm.fontSize), color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = WSpace.s16, top = WSpace.s24, bottom = WSpace.s8).semantics { heading() },
)
} else Spacer(Modifier.padding(top = WSpace.s16))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
scope.items.forEachIndexed { i, item -> item(tileShape(i, scope.items.size)) }
}
}
}
@Composable
private fun Tile(shape: Shape, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier.fillMaxWidth().clip(shape).background(MaterialTheme.colorScheme.surfaceContainerLowest).heightIn(min = 72.dp),
contentAlignment = Alignment.CenterStart,
) { content() }
}
@Composable
private fun RowContent(icon: ImageVector?, title: String, summary: String?, trailing: (@Composable () -> Unit)?) {
Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16, vertical = WSpace.s16), verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
Box(Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) {
Icon(icon, null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(22.dp))
}
Spacer(Modifier.width(WSpace.s16))
}
Column(Modifier.weight(1f)) {
Text(title, style = WType.body, color = MaterialTheme.colorScheme.onSurface)
if (summary != null) Text(summary, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (trailing != null) { Spacer(Modifier.width(WSpace.s12)); trailing() }
}
}
@Composable
fun SettingsItem(shape: Shape, title: String, summary: String? = null, icon: ImageVector? = null, onClick: (() -> Unit)? = null, trailing: (@Composable () -> Unit)? = null) {
Tile(shape, if (onClick != null) Modifier.clip(shape).clickable(onClick = onClick) else Modifier) { RowContent(icon, title, summary, trailing) }
}
@Composable
fun SettingsSwitch(shape: Shape, title: String, checked: Boolean, onChange: (Boolean) -> Unit, summary: String? = null, icon: ImageVector? = null) {
Tile(shape, Modifier.clip(shape).toggleable(checked, role = Role.Switch, onValueChange = onChange)) {
RowContent(icon, title, summary) {
Switch(
checked, onCheckedChange = null,
thumbContent = if (checked) { { Icon(Icons.Rounded.Check, null, Modifier.size(SwitchDefaults.IconSize)) } } else null,
)
}
}
}
/** A tile whose value is picked from a list in a dialog, like Pixel's radio settings. */
@Composable
fun <T> SettingsChoice(shape: Shape, title: String, options: List<T>, selected: T, label: @Composable (T) -> String, onPick: (T) -> Unit, icon: ImageVector? = null) {
var open by remember { mutableStateOf(false) }
SettingsItem(shape, title, label(selected), icon, onClick = { open = true })
if (open) {
AlertDialog(
onDismissRequest = { open = false },
title = { Text(title) },
text = {
Column(Modifier.selectableGroup()) {
options.forEach { o ->
Row(
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
.selectable(o == selected, role = Role.RadioButton) { onPick(o); open = false }
.padding(vertical = WSpace.s12, horizontal = WSpace.s4),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(o == selected, onClick = null)
Spacer(Modifier.width(WSpace.s16))
Text(label(o), style = WType.body)
}
}
}
},
confirmButton = { TextButton(onClick = { open = false }) { Text(androidx.compose.ui.res.stringResource(android.R.string.cancel)) } },
)
}
}
/** Free content inside a group tile (swatches, sliders). */
@Composable
fun SettingsCustom(shape: Shape, content: @Composable ColumnScope.() -> Unit) {
Tile(shape) { Column(Modifier.fillMaxWidth().padding(WSpace.s16), content = content) }
}
@@ -0,0 +1,337 @@
package app.telefon.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Backspace
import androidx.compose.material.icons.automirrored.rounded.CallMade
import androidx.compose.material.icons.automirrored.rounded.CallMissed
import androidx.compose.material.icons.automirrored.rounded.CallReceived
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Call
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material.icons.rounded.Voicemail
import androidx.compose.material.icons.outlined.Sms
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.telefon.R
import app.telefon.data.CallEntry
import app.telefon.data.Direction
import app.telefon.data.Person
import app.telefon.data.T9
import app.telefon.ui.components.Avatar
import app.telefon.ui.components.Fmt
import app.telefon.ui.theme.LocalCallColors
import app.telefon.ui.theme.WType
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
enum class Filter { ALL, MISSED, CONTACTS }
private fun groupShape(i: Int, n: Int): Shape = when {
n == 1 -> RoundedCornerShape(24.dp)
i == 0 -> RoundedCornerShape(24.dp, 24.dp, 4.dp, 4.dp)
i == n - 1 -> RoundedCornerShape(4.dp, 4.dp, 24.dp, 24.dp)
else -> RoundedCornerShape(4.dp)
}
/** S01 */
@Composable
fun HomeScreen(
log: List<CallEntry>, people: List<Person>, pad: PaddingValues,
onCall: (String) -> Unit, onOpenNumber: (String) -> Unit, onOpenPerson: (Person) -> Unit, onSearch: () -> Unit,
) {
var filter by rememberSaveable { mutableStateOf(Filter.ALL) }
val scheme = MaterialTheme.colorScheme
val res = LocalContext.current.resources
val favorites = people.filter { it.starred }
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = pad.calculateTopPadding(), bottom = pad.calculateBottomPadding() + 16.dp)) {
item {
Row(
Modifier.padding(16.dp).fillMaxWidth().height(56.dp).clip(RoundedCornerShape(28.dp)).background(scheme.surfaceContainer).clickable(onClick = onSearch).padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(stringResource(R.string.search_contacts), style = WType.body, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
Icon(Icons.Rounded.Search, null, tint = scheme.onSurfaceVariant)
}
}
item {
LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(Filter.entries) { f ->
FilterChip(filter == f, { filter = f }, label = {
Text(stringResource(when (f) { Filter.ALL -> R.string.f_all; Filter.MISSED -> R.string.f_missed; Filter.CONTACTS -> R.string.f_contacts }))
})
}
}
}
if (filter == Filter.CONTACTS) {
items(people, key = { "p" + it.id }) { p ->
Row(Modifier.fillMaxWidth().clickable { onOpenPerson(p) }.padding(horizontal = 24.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Avatar(p.name, p.photo, 40.dp); Spacer(Modifier.width(16.dp)); Text(p.name, style = WType.body, modifier = Modifier.weight(1f))
p.phones.firstOrNull()?.let { IconButton(onClick = { onCall(it.number) }) { Icon(Icons.Rounded.Call, stringResource(R.string.call)) } }
}
}
if (people.isEmpty()) item { Empty(stringResource(R.string.no_contacts)) }
return@LazyColumn
}
if (favorites.isNotEmpty() && filter == Filter.ALL) {
item { Header(stringResource(R.string.favorites)) }
item {
LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
items(favorites, key = { it.id }) { p ->
Column(Modifier.width(64.dp).combinedClickable(onClick = { p.phones.firstOrNull()?.let { onCall(it.number) } }, onLongClick = { onOpenPerson(p) }),
horizontalAlignment = Alignment.CenterHorizontally) {
Avatar(p.name, p.photo, 56.dp)
Text(p.name.substringBefore(' '), style = WType.label, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
}
}
}
}
}
val shown = if (filter == Filter.MISSED) log.filter { it.direction == Direction.MISSED } else log
if (shown.isEmpty()) item { Empty(stringResource(if (filter == Filter.MISSED) R.string.no_missed else R.string.no_calls)) }
shown.groupBy { Fmt.day(it.date) }.forEach { (day, calls) ->
item(key = "h$day") {
Header(when (day) { LocalDate.now() -> res.getString(R.string.today); LocalDate.now().minusDays(1) -> res.getString(R.string.yesterday); else -> day.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) })
}
itemsIndexed(calls, key = { _, c -> c.id }) { i, c ->
CallRow(c, groupShape(i, calls.size), { onCall(c.number) }) { onOpenNumber(c.number) }
}
}
}
}
@Composable private fun Header(t: String) = Text(t, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 24.dp, top = 20.dp, bottom = 8.dp))
@Composable private fun Empty(t: String) = Text(t, style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(48.dp))
@Composable
private fun CallRow(c: CallEntry, shape: Shape, onCall: () -> Unit, onOpen: () -> Unit) {
val scheme = MaterialTheme.colorScheme
val missed = c.direction == Direction.MISSED
val color = if (missed) LocalCallColors.current.missed else scheme.onSurfaceVariant
val icon = when (c.direction) {
Direction.OUTGOING -> Icons.AutoMirrored.Rounded.CallMade
Direction.MISSED -> Icons.AutoMirrored.Rounded.CallMissed
Direction.REJECTED, Direction.BLOCKED -> Icons.Rounded.Block
Direction.VOICEMAIL -> Icons.Rounded.Voicemail
Direction.INCOMING -> Icons.AutoMirrored.Rounded.CallReceived
}
val typeText = stringResource(when (c.direction) {
Direction.OUTGOING -> R.string.d_out; Direction.MISSED -> R.string.d_missed; Direction.REJECTED -> R.string.d_rejected
Direction.BLOCKED -> R.string.d_blocked; Direction.VOICEMAIL -> R.string.d_voicemail; Direction.INCOMING -> R.string.d_in
})
val title = (c.name ?: Fmt.number(c.number).ifEmpty { stringResource(R.string.unknown) }) + if (c.count > 1) " (${c.count})" else ""
Row(
Modifier.padding(horizontal = 16.dp, vertical = 1.dp).fillMaxWidth().clip(shape).background(scheme.surfaceContainerLowest).clickable(onClick = onOpen).padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = 4.dp)
.semantics(mergeDescendants = true) { contentDescription = "$title, $typeText, ${Fmt.time(c.date)}" },
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(c.name, c.photo, 40.dp)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(title, style = WType.body, color = if (missed) color else scheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, Modifier.size(14.dp), tint = color)
Text(" " + (c.label?.takeIf { c.name != null } ?: typeText) + " · " + Fmt.time(c.date), style = WType.bodySm, color = color, maxLines = 1)
}
}
if (c.number.isNotBlank()) IconButton(onClick = onCall) { Icon(Icons.Rounded.Call, stringResource(R.string.call)) }
}
}
/** S02 */
@Composable
fun SearchScreen(people: List<Person>, pad: PaddingValues, onCall: (String) -> Unit, onOpenPerson: (Person) -> Unit) {
var q by rememberSaveable { mutableStateOf("") }
val hits = remember(q, people) {
if (q.isBlank()) people.take(40) else people.filter { p -> p.name.contains(q, true) || p.phones.any { it.number.filter(Char::isDigit).contains(q.filter(Char::isDigit).ifEmpty { "§" }) } }
}
Column(Modifier.fillMaxSize().padding(pad)) {
TextField(q, { q = it }, Modifier.fillMaxWidth().padding(16.dp).clip(RoundedCornerShape(28.dp)), placeholder = { Text(stringResource(R.string.search_contacts)) },
leadingIcon = { Icon(Icons.Rounded.Search, null) }, singleLine = true,
colors = TextFieldDefaults.colors(focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent))
LazyColumn {
if (q.any(Char::isDigit)) item {
Row(Modifier.fillMaxWidth().clickable { onCall(q) }.padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Call, null); Spacer(Modifier.width(16.dp)); Text(stringResource(R.string.call_number, q), style = WType.body)
}
}
items(hits, key = { it.id }) { p ->
Row(Modifier.fillMaxWidth().clickable { onOpenPerson(p) }.padding(horizontal = 24.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Avatar(p.name, p.photo, 40.dp); Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) { Text(p.name, style = WType.body); p.phones.firstOrNull()?.let { Text("${it.label} · ${Fmt.number(it.number)}", style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) } }
}
}
}
}
}
/** 3×4 keys with letters; used by S03 and the in-call keypad. */
@Composable
fun DialPad(onKey: (Char) -> Unit) {
val letters = mapOf('2' to "ABC", '3' to "DEF", '4' to "GHI", '5' to "JKL", '6' to "MNO", '7' to "PQRS", '8' to "TUV", '9' to "WXYZ", '0' to "+")
val view = androidx.compose.ui.platform.LocalView.current
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(horizontal = 32.dp)) {
listOf("123", "456", "789", "*0#").forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
row.forEach { k ->
Column(
Modifier.weight(1f).height(68.dp).clip(RoundedCornerShape(34.dp)).background(MaterialTheme.colorScheme.surfaceContainer)
.clickable { view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP); onKey(k) },
horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center,
) {
Text(k.toString(), style = WType.key)
letters[k]?.let { Text(it, style = WType.label, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
}
}
}
}
/** S03 */
@Composable
fun KeypadScreen(people: List<Person>, pad: PaddingValues, initial: String, onCall: (String) -> Unit) {
var number by rememberSaveable(initial) { mutableStateOf(initial) }
val digits = number.filter(Char::isDigit)
val matches = remember(digits, people) { if (digits.isEmpty()) emptyList() else people.filter { T9.matches(it, digits) }.take(3) }
Column(Modifier.fillMaxSize().padding(pad), horizontalAlignment = Alignment.CenterHorizontally) {
Column(Modifier.weight(1f).fillMaxWidth()) {
matches.forEach { p ->
Row(Modifier.fillMaxWidth().clickable { p.phones.firstOrNull()?.let { onCall(it.number) } }.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Avatar(p.name, p.photo, 40.dp); Spacer(Modifier.width(16.dp))
Column { Text(p.name, style = WType.body); p.phones.firstOrNull()?.let { Text(Fmt.number(it.number), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) } }
}
}
}
Row(Modifier.fillMaxWidth().padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(48.dp))
Text(Fmt.number(number).ifEmpty { number }, style = WType.dial, textAlign = TextAlign.Center, maxLines = 1, modifier = Modifier.weight(1f))
if (number.isNotEmpty()) Box(Modifier.size(48.dp).combinedClickable(onClick = { number = number.dropLast(1) }, onLongClick = { number = "" }), contentAlignment = Alignment.Center) {
Icon(Icons.AutoMirrored.Rounded.Backspace, stringResource(R.string.backspace))
} else Spacer(Modifier.width(48.dp))
}
Spacer(Modifier.height(16.dp))
DialPad { number += it }
Spacer(Modifier.height(16.dp))
val cc = LocalCallColors.current
Box(Modifier.size(72.dp).clip(CircleShape).background(cc.accept).clickable(enabled = number.isNotBlank()) { onCall(number) }, contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.Call, stringResource(R.string.call), tint = cc.onAccept, modifier = Modifier.size(32.dp))
}
Spacer(Modifier.height(24.dp))
}
}
/** S04 */
@Composable
fun VoicemailScreen(number: String?, pad: PaddingValues, onCall: (String) -> Unit) {
Column(Modifier.fillMaxSize().padding(pad).padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(Icons.Rounded.Voicemail, null, Modifier.size(72.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(16.dp))
Text(if (number != null) stringResource(R.string.voicemail_number, Fmt.number(number)) else stringResource(R.string.voicemail_none), style = WType.body, textAlign = TextAlign.Center)
Spacer(Modifier.height(24.dp))
if (number != null) Button(onClick = { onCall(number) }) { Text(stringResource(R.string.voicemail_call)) }
}
}
/** S05: a contact, or just a number from the log. */
@Composable
fun DetailsScreen(
person: Person?, number: String?, log: List<CallEntry>, pad: PaddingValues,
onCall: (String) -> Unit, onSms: (String) -> Unit, onStar: (Person) -> Unit, onAddContact: (String) -> Unit, onOpenContacts: (Person) -> Unit,
) {
val numbers = person?.phones?.map { it.number } ?: listOfNotNull(number)
val calls = log.filter { e -> numbers.any { android.telephony.PhoneNumberUtils.compare(it, e.number) } }
LazyColumn(Modifier.fillMaxSize(), contentPadding = pad) {
item {
Column(Modifier.fillMaxWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Avatar(person?.name, person?.photo, 120.dp, cookie = true)
Spacer(Modifier.height(16.dp))
Text(person?.name ?: Fmt.number(number.orEmpty()), style = WType.caller.copy(fontSize = WType.title.fontSize * 1.3f), textAlign = TextAlign.Center)
Row(Modifier.padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
numbers.firstOrNull()?.let { n ->
Action(Icons.Rounded.Call, stringResource(R.string.call)) { onCall(n) }
Action(Icons.Outlined.Sms, stringResource(R.string.sms)) { onSms(n) }
}
if (person != null) Action(if (person.starred) Icons.Rounded.Star else Icons.Rounded.StarBorder, stringResource(R.string.favorite)) { onStar(person) }
else number?.let { Action(Icons.Outlined.PersonAdd, stringResource(R.string.add_contact)) { onAddContact(it) } }
}
}
}
person?.phones?.let { phones ->
itemsIndexed(phones) { i, p ->
Row(Modifier.padding(horizontal = 16.dp, vertical = 1.dp).fillMaxWidth().clip(groupShape(i, phones.size)).background(MaterialTheme.colorScheme.surfaceContainerLowest)
.clickable { onCall(p.number) }.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Text(Fmt.number(p.number), style = WType.body); Text(p.label, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) }
IconButton(onClick = { onSms(p.number) }) { Icon(Icons.Outlined.Sms, stringResource(R.string.sms)) }
}
}
item { androidx.compose.material3.TextButton(onClick = { onOpenContacts(person) }, modifier = Modifier.padding(start = 16.dp)) { Text(stringResource(R.string.open_in_contacts)) } }
}
if (calls.isNotEmpty()) item { Header(stringResource(R.string.history)) }
itemsIndexed(calls.take(30)) { i, c ->
Row(Modifier.padding(horizontal = 16.dp, vertical = 1.dp).fillMaxWidth().clip(groupShape(i, calls.take(30).size)).background(MaterialTheme.colorScheme.surfaceContainerLowest).padding(16.dp)) {
Text(c.date.let { java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault()).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)) }, style = WType.bodySm, modifier = Modifier.weight(1f))
Text(if (c.durationS > 0) Fmt.duration(c.durationS * 1000) else "–", style = WType.bodySm)
}
}
}
}
@Composable
private fun Action(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(72.dp)) {
Box(Modifier.size(56.dp).clip(CircleShape).background(MaterialTheme.colorScheme.secondaryContainer).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
Icon(icon, label, tint = MaterialTheme.colorScheme.onSecondaryContainer)
}
Text(label, style = WType.label, modifier = Modifier.padding(top = 6.dp))
}
}
@@ -0,0 +1,39 @@
package app.telefon.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
/** Call colours stay fixed (green answer, red hang up) so they read the same on every wallpaper. */
@Immutable
data class CallColors(val accept: Color, val onAccept: Color, val hangup: Color, val onHangup: Color, val missed: Color)
private val LightCall = CallColors(WColor.accept, WColor.onAccept, WColor.hangup, WColor.onHangup, WColor.missed)
private val DarkCall = CallColors(WColor.darkAccept, WColor.darkOnAccept, WColor.darkHangup, WColor.darkOnHangup, WColor.darkMissed)
val LocalCallColors = staticCompositionLocalOf { LightCall }
@Composable
fun PhoneTheme(dynamic: Boolean = true, forceDark: Boolean? = null, content: @Composable () -> Unit) {
val dark = forceDark ?: isSystemInDarkTheme()
val ctx = LocalContext.current
val scheme = when {
dynamic && Build.VERSION.SDK_INT >= 31 -> if (dark) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
dark -> darkColorScheme(primary = WColor.darkToggleOn, onPrimary = WColor.darkOnToggleOn, surface = WColor.darkBg, background = WColor.darkBg,
surfaceContainerLow = WColor.darkCard, surfaceContainerLowest = WColor.darkCard, surfaceContainer = WColor.darkToggle,
secondaryContainer = WColor.darkChipOn, onSecondaryContainer = WColor.darkOnChipOn, onSurface = WColor.darkText, onSurfaceVariant = WColor.darkTextMuted)
else -> lightColorScheme(primary = WColor.onChipOn, surface = WColor.bg, background = WColor.bg,
surfaceContainerLow = WColor.card, surfaceContainerLowest = WColor.card, surfaceContainer = WColor.slideTrack,
secondaryContainer = WColor.chipOn, onSecondaryContainer = WColor.onChipOn, onSurface = WColor.text, onSurfaceVariant = WColor.textMuted)
}
CompositionLocalProvider(LocalCallColors provides if (dark) DarkCall else LightCall) { MaterialTheme(colorScheme = scheme, content = content) }
}
@@ -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 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"><path android:fillColor="#FF1B5E3A" android:pathData="M0,0h108v108h-108z"/></vector>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FFC4EED0" android:pathData="M82.22,54.00 L82.03,55.22 L81.50,56.41 L80.77,57.52 L79.99,58.58 L79.33,59.62 L78.91,60.67 L78.75,61.80 L78.80,63.03 L78.94,64.33 L79.02,65.67 L78.88,66.95 L78.43,68.11 L77.66,69.07 L76.61,69.84 L75.42,70.44 L74.22,70.96 L73.13,71.53 L72.23,72.23 L71.53,73.13 L70.96,74.22 L70.44,75.42 L69.84,76.61 L69.07,77.66 L68.11,78.43 L66.95,78.88 L65.67,79.02 L64.33,78.94 L63.03,78.80 L61.80,78.75 L60.67,78.91 L59.62,79.33 L58.58,79.99 L57.52,80.77 L56.41,81.50 L55.22,82.03 L54.00,82.22 L52.78,82.03 L51.59,81.50 L50.48,80.77 L49.42,79.99 L48.38,79.33 L47.33,78.91 L46.20,78.75 L44.97,78.80 L43.67,78.94 L42.33,79.02 L41.05,78.88 L39.89,78.43 L38.93,77.66 L38.16,76.61 L37.56,75.42 L37.04,74.22 L36.47,73.13 L35.77,72.23 L34.87,71.53 L33.78,70.96 L32.58,70.44 L31.39,69.84 L30.34,69.07 L29.57,68.11 L29.12,66.95 L28.98,65.67 L29.06,64.33 L29.20,63.03 L29.25,61.80 L29.09,60.67 L28.67,59.62 L28.01,58.58 L27.23,57.52 L26.50,56.41 L25.97,55.22 L25.79,54.00 L25.97,52.78 L26.50,51.59 L27.23,50.48 L28.01,49.42 L28.67,48.38 L29.09,47.33 L29.25,46.20 L29.20,44.97 L29.06,43.67 L28.98,42.33 L29.12,41.05 L29.57,39.89 L30.34,38.93 L31.39,38.16 L32.58,37.56 L33.78,37.04 L34.87,36.47 L35.77,35.77 L36.47,34.87 L37.04,33.78 L37.56,32.58 L38.16,31.39 L38.93,30.34 L39.89,29.57 L41.05,29.12 L42.33,28.98 L43.67,29.06 L44.97,29.20 L46.20,29.25 L47.33,29.09 L48.38,28.67 L49.42,28.01 L50.48,27.23 L51.59,26.50 L52.78,25.97 L54.00,25.79 L55.22,25.97 L56.41,26.50 L57.52,27.23 L58.58,28.01 L59.62,28.67 L60.67,29.09 L61.80,29.25 L63.03,29.20 L64.33,29.06 L65.67,28.98 L66.95,29.12 L68.11,29.57 L69.07,30.34 L69.84,31.39 L70.44,32.58 L70.96,33.78 L71.53,34.87 L72.23,35.77 L73.13,36.47 L74.22,37.04 L75.42,37.56 L76.61,38.16 L77.66,38.93 L78.43,39.89 L78.88,41.05 L79.02,42.33 L78.94,43.67 L78.80,44.97 L78.75,46.20 L78.91,47.33 L79.33,48.38 L79.99,49.42 L80.77,50.48 L81.50,51.59 L82.03,52.78 Z"/>
<path android:fillColor="#FF0B3D20" android:pathData="M40.5,34.5c1.6,-1.6 4.2,-1.4 5.5,0.4l4.2,5.8c1.1,1.5 1,3.6 -0.3,4.9l-2.6,2.6c1.9,4.4 5.8,8.4 10.3,10.4l2.6,-2.6c1.3,-1.3 3.4,-1.5 4.9,-0.4l5.8,4.2c1.8,1.3 2,3.9 0.4,5.5l-2.6,2.6c-2.4,2.4 -6.1,3.1 -9.2,1.7c-9.6,-4.3 -17.3,-12 -21.6,-21.6c-1.4,-3.1 -0.7,-6.8 1.7,-9.2z"/>
</vector>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FF000000" android:pathData="M82.22,54.00 L82.03,55.22 L81.50,56.41 L80.77,57.52 L79.99,58.58 L79.33,59.62 L78.91,60.67 L78.75,61.80 L78.80,63.03 L78.94,64.33 L79.02,65.67 L78.88,66.95 L78.43,68.11 L77.66,69.07 L76.61,69.84 L75.42,70.44 L74.22,70.96 L73.13,71.53 L72.23,72.23 L71.53,73.13 L70.96,74.22 L70.44,75.42 L69.84,76.61 L69.07,77.66 L68.11,78.43 L66.95,78.88 L65.67,79.02 L64.33,78.94 L63.03,78.80 L61.80,78.75 L60.67,78.91 L59.62,79.33 L58.58,79.99 L57.52,80.77 L56.41,81.50 L55.22,82.03 L54.00,82.22 L52.78,82.03 L51.59,81.50 L50.48,80.77 L49.42,79.99 L48.38,79.33 L47.33,78.91 L46.20,78.75 L44.97,78.80 L43.67,78.94 L42.33,79.02 L41.05,78.88 L39.89,78.43 L38.93,77.66 L38.16,76.61 L37.56,75.42 L37.04,74.22 L36.47,73.13 L35.77,72.23 L34.87,71.53 L33.78,70.96 L32.58,70.44 L31.39,69.84 L30.34,69.07 L29.57,68.11 L29.12,66.95 L28.98,65.67 L29.06,64.33 L29.20,63.03 L29.25,61.80 L29.09,60.67 L28.67,59.62 L28.01,58.58 L27.23,57.52 L26.50,56.41 L25.97,55.22 L25.79,54.00 L25.97,52.78 L26.50,51.59 L27.23,50.48 L28.01,49.42 L28.67,48.38 L29.09,47.33 L29.25,46.20 L29.20,44.97 L29.06,43.67 L28.98,42.33 L29.12,41.05 L29.57,39.89 L30.34,38.93 L31.39,38.16 L32.58,37.56 L33.78,37.04 L34.87,36.47 L35.77,35.77 L36.47,34.87 L37.04,33.78 L37.56,32.58 L38.16,31.39 L38.93,30.34 L39.89,29.57 L41.05,29.12 L42.33,28.98 L43.67,29.06 L44.97,29.20 L46.20,29.25 L47.33,29.09 L48.38,28.67 L49.42,28.01 L50.48,27.23 L51.59,26.50 L52.78,25.97 L54.00,25.79 L55.22,25.97 L56.41,26.50 L57.52,27.23 L58.58,28.01 L59.62,28.67 L60.67,29.09 L61.80,29.25 L63.03,29.20 L64.33,29.06 L65.67,28.98 L66.95,29.12 L68.11,29.57 L69.07,30.34 L69.84,31.39 L70.44,32.58 L70.96,33.78 L71.53,34.87 L72.23,35.77 L73.13,36.47 L74.22,37.04 L75.42,37.56 L76.61,38.16 L77.66,38.93 L78.43,39.89 L78.88,41.05 L79.02,42.33 L78.94,43.67 L78.80,44.97 L78.75,46.20 L78.91,47.33 L79.33,48.38 L79.99,49.42 L80.77,50.48 L81.50,51.59 L82.03,52.78 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M40.5,34.5c1.6,-1.6 4.2,-1.4 5.5,0.4l4.2,5.8c1.1,1.5 1,3.6 -0.3,4.9l-2.6,2.6c1.9,4.4 5.8,8.4 10.3,10.4l2.6,-2.6c1.3,-1.3 3.4,-1.5 4.9,-0.4l5.8,4.2c1.8,1.3 2,3.9 0.4,5.5l-2.6,2.6c-2.4,2.4 -6.1,3.1 -9.2,1.7c-9.6,-4.3 -17.3,-12 -21.6,-21.6c-1.4,-3.1 -0.7,-6.8 1.7,-9.2z"/>
</vector>
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFFFF" android:pathData="M6.6,10.8c1.4,2.8 3.8,5.1 6.6,6.6l2.2,-2.2c0.3,-0.3 0.7,-0.4 1,-0.2 1.1,0.4 2.3,0.6 3.6,0.6 0.6,0 1,0.4 1,1V20c0,0.6 -0.4,1 -1,1 -9.4,0 -17,-7.6 -17,-17 0,-0.6 0.4,-1 1,-1h3.5c0.6,0 1,0.4 1,1 0,1.3 0.2,2.5 0.6,3.6 0.1,0.3 0,0.7 -0.2,1L6.6,10.8z"/></vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="tab_home">Start</string>
<string name="tab_keypad">Wähltasten</string>
<string name="tab_voicemail">Mailbox</string>
<string name="search">Suchen</string>
<string name="search_contacts">Kontakte durchsuchen</string>
<string name="back">Zurück</string>
<string name="f_all">Alle</string>
<string name="f_missed">Verpasst</string>
<string name="f_contacts">Kontakte</string>
<string name="favorites">Favoriten</string>
<string name="today">Heute</string>
<string name="yesterday">Gestern</string>
<string name="no_calls">Noch keine Anrufe</string>
<string name="no_missed">Keine verpassten Anrufe</string>
<string name="no_contacts">Keine Kontakte mit Telefonnummer</string>
<string name="unknown">Unbekannt</string>
<string name="call">Anrufen</string>
<string name="call_number">%1$s anrufen</string>
<string name="sms">SMS</string>
<string name="favorite">Favorit</string>
<string name="add_contact">Hinzufügen</string>
<string name="open_in_contacts">In Kontakte öffnen</string>
<string name="history">Anrufe</string>
<string name="backspace">Löschen</string>
<string name="d_in">Eingehend</string>
<string name="d_out">Ausgehend</string>
<string name="d_missed">Verpasst</string>
<string name="d_rejected">Abgelehnt</string>
<string name="d_blocked">Blockiert</string>
<string name="d_voicemail">Mailbox</string>
<string name="voicemail_number">Deine Mailbox: %1$s</string>
<string name="voicemail_none">Für diese SIM-Karte ist keine Mailbox-Nummer hinterlegt.</string>
<string name="voicemail_call">Mailbox anrufen</string>
<string name="default_title">Als Standard-Telefon-App festlegen</string>
<string name="default_text">Nur dann kann diese App Anrufe annehmen und anzeigen. Du kannst das jederzeit in den Android-Einstellungen ändern.</string>
<string name="not_default_call">Diese App ist noch nicht deine Standard-Telefon-App. Android zeigt den Anruf deshalb in der System-Telefon-App an.</string>
<string name="make_default">Als Standard festlegen</string>
<string name="call_anyway">Trotzdem anrufen</string>
<string name="incoming_call">Eingehender Anruf</string>
<string name="calling">Wird angerufen …</string>
<string name="on_hold">Gehalten</string>
<string name="call_ended">Anruf beendet</string>
<string name="decline">Ablehnen</string>
<string name="answer">Annehmen</string>
<string name="message">Nachricht</string>
<string name="reply_1">Ich kann gerade nicht sprechen.</string>
<string name="reply_2">Ich rufe dich gleich zurück.</string>
<string name="reply_3">Bin unterwegs, melde mich später.</string>
<string name="keypad">Tasten</string>
<string name="mute">Stumm</string>
<string name="speaker">Lautspr.</string>
<string name="more">Mehr</string>
<string name="hold">Halten</string>
<string name="resume">Fortsetzen</string>
<string name="bluetooth">Bluetooth</string>
<string name="hang_up">Auflegen</string>
<string name="ongoing_call">Laufender Anruf</string>
<string name="ch_incoming">Eingehende Anrufe</string>
<string name="ch_ongoing">Laufende Anrufe</string>
</resources>
@@ -0,0 +1,52 @@
package app.telefon
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import app.telefon.call.AudioUi
import app.telefon.call.CallUi
import app.telefon.call.IncomingScreen
import app.telefon.call.InCallScreen
import app.telefon.call.Phase
import app.telefon.data.CallEntry
import app.telefon.data.Direction
import app.telefon.data.Person
import app.telefon.data.Phone
import app.telefon.ui.screens.HomeScreen
import app.telefon.ui.screens.KeypadScreen
import app.telefon.ui.theme.PhoneTheme
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Made-up people and numbers (+49 30 555 test range), no real data. */
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [35], qualifiers = "de-rDE-w412dp-h915dp-xxhdpi", application = android.app.Application::class)
class ScreenshotTest {
@get:Rule val compose = createComposeRule()
private val now = System.currentTimeMillis()
private val people = listOf("Alessa Brandt", "Ben Okafor", "Clara Wu", "David Roth", "Emil Sander").mapIndexed { i, n ->
Person(i.toLong(), "k$i", n, null, i < 5, listOf(Phone("+49305550${i}12", "Mobil")))
}
private val log = listOf(
CallEntry(1, "+4930555012", "Clara Wu", null, Direction.INCOMING, now - 600_000, 120, "Mobil"),
CallEntry(2, "+4930555099", null, null, Direction.MISSED, now - 3_600_000, 0, null, count = 2),
CallEntry(3, "+4930555013", "Ben Okafor", null, Direction.OUTGOING, now - 90_000_000, 300, "Mobil"),
CallEntry(4, "+4930555077", "Paketdienst", null, Direction.INCOMING, now - 95_000_000, 40, "Arbeit"),
)
private fun shot(name: String, dark: Boolean, c: @Composable () -> Unit) {
compose.setContent { PhoneTheme(dynamic = false, forceDark = dark) { c() } }
compose.onRoot().captureRoboImage("../../replica/telefon/clone-screens/$name.png")
}
@Test fun s01_home() = shot("S01-home-light", false) { HomeScreen(log, people, PaddingValues(), {}, {}, {}, {}) }
@Test fun s01_home_dark() = shot("S01-home-dark", true) { HomeScreen(log, people, PaddingValues(), {}, {}, {}, {}) }
@Test fun s03_keypad() = shot("S03-keypad-light", false) { KeypadScreen(people, PaddingValues(), "2525", {}) }
@Test fun s06_incoming() = shot("S06-incoming-light", false) { IncomingScreen(CallUi("a", "+4930555099", Phase.RINGING, null, true), {}, {}, {}) }
@Test fun s07_incall() = shot("S07-incall-dark", true) { InCallScreen(CallUi("a", "+4930555099", Phase.ACTIVE, now - 53_000, true), AudioUi(muted = true), 1, nowOverride = now) }
}
+7
View File
@@ -0,0 +1,7 @@
plugins {
id("com.android.application") version "8.13.0" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
id("com.google.devtools.ksp") version "2.2.20-2.0.3" apply false
id("io.github.takahirom.roborazzi") version "1.46.1" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Telefon"
include(":app")
+2 -2
View File
@@ -16,8 +16,8 @@ android {
applicationId = "app.uhr" applicationId = "app.uhr"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
versionCode = 4 versionCode = 5
versionName = "0.2.0" versionName = "0.2.1"
} }
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only). // Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
+16
View File
@@ -11,6 +11,7 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="com.android.alarm.permission.SET_ALARM" />
<application <application
android:name=".UhrApp" android:name=".UhrApp"
@@ -28,6 +29,21 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name=".AlarmIntentActivity" android:exported="true" android:excludeFromRecents="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar" android:noHistory="true">
<intent-filter>
<action android:name="android.intent.action.SET_ALARM" />
<action android:name="android.intent.action.SET_TIMER" />
<action android:name="android.intent.action.SHOW_ALARMS" />
<action android:name="android.intent.action.SHOW_TIMERS" />
<action android:name="android.intent.action.DISMISS_ALARM" />
<action android:name="android.intent.action.DISMISS_TIMER" />
<action android:name="android.intent.action.SNOOZE_ALARM" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.VOICE" />
</intent-filter>
</activity>
<activity <activity
android:name=".alarm.RingingActivity" android:name=".alarm.RingingActivity"
android:exported="false" android:exported="false"
@@ -0,0 +1,69 @@
package app.uhr
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.provider.AlarmClock
import android.widget.Toast
import app.uhr.alarm.Ring
import app.uhr.alarm.RingingService
import app.uhr.data.Alarm
import app.uhr.data.Graph
import app.uhr.ui.components.Fmt
import kotlinx.coroutines.runBlocking
import java.util.Calendar
/**
* The public AlarmClock intents, so other apps and voice assistants can use this app
* instead of the system clock: set/show alarms and timers, dismiss and snooze.
*/
class AlarmIntentActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Graph.init(this)
val i = intent
val skipUi = i.getBooleanExtra(AlarmClock.EXTRA_SKIP_UI, false)
when (i.action) {
AlarmClock.ACTION_SET_ALARM -> setAlarm(i, skipUi)
AlarmClock.ACTION_SET_TIMER -> setTimer(i, skipUi)
AlarmClock.ACTION_SHOW_ALARMS -> open(0)
AlarmClock.ACTION_SHOW_TIMERS -> open(2)
AlarmClock.ACTION_DISMISS_ALARM, AlarmClock.ACTION_DISMISS_TIMER -> RingingService.ringing.value?.let { RingingService.stop(this) } ?: open(0)
AlarmClock.ACTION_SNOOZE_ALARM -> if (RingingService.ringing.value is Ring.AlarmRing) RingingService.snooze(this) else open(0)
else -> open(0)
}
finish()
}
private fun setAlarm(i: Intent, skipUi: Boolean) {
if (!i.hasExtra(AlarmClock.EXTRA_HOUR)) { open(0, newAlarm = true); return }
val hour = i.getIntExtra(AlarmClock.EXTRA_HOUR, 7).coerceIn(0, 23)
val minute = i.getIntExtra(AlarmClock.EXTRA_MINUTES, 0).coerceIn(0, 59)
// EXTRA_DAYS uses Calendar.SUNDAY = 1 … SATURDAY = 7; we store Monday = bit 0
val days = i.getIntegerArrayListExtra(AlarmClock.EXTRA_DAYS).orEmpty().fold(0) { acc, d ->
val mondayBased = if (d == Calendar.SUNDAY) 6 else d - 2
if (mondayBased in 0..6) acc or (1 shl mondayBased) else acc
}
val ringtone = i.getStringExtra(AlarmClock.EXTRA_RINGTONE)?.let { if (it == AlarmClock.VALUE_RINGTONE_SILENT) Alarm.SILENT else it }
val alarm = Alarm(
hour = hour, minute = minute, days = days, label = i.getStringExtra(AlarmClock.EXTRA_MESSAGE).orEmpty(),
vibrate = i.getBooleanExtra(AlarmClock.EXTRA_VIBRATE, true), ringtoneUri = ringtone,
)
val id = runBlocking { Graph.repo.save(alarm) }
val inst = runBlocking { Graph.repo.instance(id) }
Fmt.is24h = android.text.format.DateFormat.is24HourFormat(this)
inst?.let { Toast.makeText(this, getString(R.string.alarm_set_in, Fmt.until(resources, it.fireAt)), Toast.LENGTH_SHORT).show() }
if (!skipUi) open(0)
}
private fun setTimer(i: Intent, skipUi: Boolean) {
val seconds = i.getIntExtra(AlarmClock.EXTRA_LENGTH, 0)
if (seconds <= 0) { open(2); return }
runBlocking { Graph.repo.startTimer(seconds * 1000L, i.getStringExtra(AlarmClock.EXTRA_MESSAGE).orEmpty()) }
if (!skipUi) open(2)
}
private fun open(tab: Int, newAlarm: Boolean = false) = startActivity(
Intent(this, MainActivity::class.java).putExtra(MainActivity.EXTRA_TAB, tab).putExtra(MainActivity.EXTRA_NEW_ALARM, newAlarm).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
+10 -3
View File
@@ -80,6 +80,7 @@ import java.time.LocalTime
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var tabFromIntent = mutableIntStateOf(-1) private var tabFromIntent = mutableIntStateOf(-1)
private val newAlarm = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -87,15 +88,17 @@ class MainActivity : ComponentActivity() {
Graph.init(this) Graph.init(this)
Fmt.is24h = DateFormat.is24HourFormat(this) Fmt.is24h = DateFormat.is24HourFormat(this)
tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1) tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1)
newAlarm.value = intent.getBooleanExtra(EXTRA_NEW_ALARM, false)
setContent { setContent {
val settings by Graph.repo.prefs.settings.collectAsState(initial = Settings()) val settings by Graph.repo.prefs.settings.collectAsState(initial = Settings())
ClockTheme(settings.theme) { ClockApp(settings, tabFromIntent.intValue) } ClockTheme(settings.theme) { ClockApp(settings, tabFromIntent.intValue, newAlarm.value) { newAlarm.value = false } }
} }
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1) tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1)
newAlarm.value = intent.getBooleanExtra(EXTRA_NEW_ALARM, false)
} }
override fun onResume() { override fun onResume() {
@@ -103,7 +106,10 @@ class MainActivity : ComponentActivity() {
Fmt.is24h = DateFormat.is24HourFormat(this) Fmt.is24h = DateFormat.is24HourFormat(this)
} }
companion object { const val EXTRA_TAB = "tab" } companion object {
const val EXTRA_TAB = "tab"
const val EXTRA_NEW_ALARM = "new_alarm"
}
} }
private data class Tab(val title: Int, val icon: ImageVector) private data class Tab(val title: Int, val icon: ImageVector)
@@ -118,7 +124,7 @@ private val TABS = listOf(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ClockApp(settings: Settings, requestedTab: Int) { private fun ClockApp(settings: Settings, requestedTab: Int, requestNewAlarm: Boolean = false, onNewAlarmHandled: () -> Unit = {}) {
val repo = Graph.repo val repo = Graph.repo
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
@@ -127,6 +133,7 @@ private fun ClockApp(settings: Settings, requestedTab: Int) {
var showSettings by rememberSaveable { mutableStateOf(false) } var showSettings by rememberSaveable { mutableStateOf(false) }
var menu by remember { mutableStateOf(false) } var menu by remember { mutableStateOf(false) }
var addAlarm by remember { mutableStateOf(false) } var addAlarm by remember { mutableStateOf(false) }
LaunchedEffect(requestNewAlarm) { if (requestNewAlarm) { tab = 0; addAlarm = true; onNewAlarmHandled() } }
var addCity by remember { mutableStateOf(false) } var addCity by remember { mutableStateOf(false) }
var keypad by rememberSaveable { mutableStateOf(false) } var keypad by rememberSaveable { mutableStateOf(false) }
var digits by rememberSaveable { mutableStateOf("") } var digits by rememberSaveable { mutableStateOf("") }