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]>
This commit is contained in:
2026-10-17 16:30:12 +02:00
co-authored by Claude Opus 5.5
parent 0f709aaff8
commit 830e653690
45 changed files with 2346 additions and 40 deletions
+43
View File
@@ -8,6 +8,7 @@
| App | Replaces | Status | Install | | App | Replaces | Status | Install |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| **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 +45,24 @@ 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.
### 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 +107,17 @@ 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 |
| --- | --- |
| 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 +161,16 @@ 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
```
**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 +189,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 +229,7 @@ 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)
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: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+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

-1
View File
@@ -5,4 +5,3 @@ CallLogGroup: rows inside one card per day, outer radius 24, inner 4, 2dp gaps (
SlideToAnswer: track pill, labels "Ablehnen" left / "Annehmen" right, thumb with phone icon; past 50 % acts; custom accessibility actions. 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. 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. Keypad: 3×4 keys (digit 30sp + letters 11sp), green round call FAB 72dp, backspace.
```
+28 -28
View File
@@ -1,33 +1,33 @@
feature,area,priority,original,clone,notes feature,area,priority,original,clone,notes
Become the default phone app (role request),core,must,yes,no, Become the default phone app (role request),core,must,yes,yes,
Call log grouped by day with direction icons,home,must,yes,no, Call log grouped by day with direction icons,home,must,yes,yes,
Call back from the log in one tap,home,must,yes,no, Call back from the log in one tap,home,must,yes,yes,
Missed calls highlighted,home,must,yes,no, Missed calls highlighted,home,must,yes,yes,
Filter chips All Missed Contacts,home,should,yes,no, Filter chips All Missed Contacts,home,should,yes,yes,
Favourites row (starred contacts),home,must,yes,no, Favourites row (starred contacts),home,must,yes,yes,
Search contacts and numbers,search,must,yes,no, Search contacts and numbers,search,must,yes,yes,
Keypad with letters,keypad,must,yes,no, Keypad with letters,keypad,must,yes,yes,
T9 contact suggestions on the keypad,keypad,should,yes,no, T9 contact suggestions on the keypad,keypad,should,yes,yes,
Place a call,call,must,yes,no, Place a call,call,must,yes,yes,
Incoming call full screen with slide answer/decline,call,must,yes,no, Incoming call full screen with slide answer/decline,call,must,yes,yes,
Incoming call notification with actions,call,must,yes,no, Incoming call notification with actions,call,must,yes,yes,
In-call screen with timer,call,must,yes,no, In-call screen with timer,call,must,yes,yes,
Mute,call,must,yes,no, Mute,call,must,yes,yes,
Speaker,call,must,yes,no, Speaker,call,must,yes,yes,
In-call keypad (DTMF),call,must,yes,no, In-call keypad (DTMF),call,must,yes,yes,
Hold,call,should,yes,no, Hold,call,should,yes,yes,
Second incoming call (call waiting),call,should,yes,no, 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,no, Ongoing call notification,call,must,yes,yes,
Proximity sensor screen off at the ear,call,must,yes,no, Proximity sensor screen off at the ear,call,must,yes,yes,
Decline with quick reply SMS,call,should,yes,no, Decline with quick reply SMS,call,should,yes,yes,
Contact and number details with history,details,should,yes,no, Contact and number details with history,details,should,yes,yes,
Toggle favourite,details,could,yes,no, Toggle favourite,details,could,yes,yes,
Open contact in contacts app / add contact,details,should,yes,no, Open contact in contacts app / add contact,details,should,yes,yes,
Call voicemail,voicemail,should,yes,no, Call voicemail,voicemail,should,yes,yes,
Delete call log entries,home,could,yes,no, Delete call log entries,home,could,yes,no,
Expressive design (scalloped avatars grouped cards slide pill),design,must,yes,no, Expressive design (scalloped avatars grouped cards slide pill),design,must,yes,yes,
Dynamic colour and dark mode,design,must,yes,no, Dynamic colour and dark mode,design,must,yes,yes,
Bluetooth audio route,call,should,yes,no, Bluetooth audio route,call,should,yes,yes,
Spam detection and caller ID,home,could,yes,skip,Google database Spam detection and caller ID,home,could,yes,skip,Google database
Call screening and hold for me,call,could,yes,skip,Google AI 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 Call recording and live translation,call,could,yes,skip,Google AI; recording blocked for apps
1 feature area priority original clone notes
2 Become the default phone app (role request) core must yes no yes
3 Call log grouped by day with direction icons home must yes no yes
4 Call back from the log in one tap home must yes no yes
5 Missed calls highlighted home must yes no yes
6 Filter chips All Missed Contacts home should yes no yes
7 Favourites row (starred contacts) home must yes no yes
8 Search contacts and numbers search must yes no yes
9 Keypad with letters keypad must yes no yes
10 T9 contact suggestions on the keypad keypad should yes no yes
11 Place a call call must yes no yes
12 Incoming call full screen with slide answer/decline call must yes no yes
13 Incoming call notification with actions call must yes no yes
14 In-call screen with timer call must yes no yes
15 Mute call must yes no yes
16 Speaker call must yes no yes
17 In-call keypad (DTMF) call must yes no yes
18 Hold call should yes no yes
19 Second incoming call (call waiting) call should yes no partial the incoming screen appears over the active call; merging/swapping UI missing
20 Ongoing call notification call must yes no yes
21 Proximity sensor screen off at the ear call must yes no yes
22 Decline with quick reply SMS call should yes no yes
23 Contact and number details with history details should yes no yes
24 Toggle favourite details could yes no yes
25 Open contact in contacts app / add contact details should yes no yes
26 Call voicemail voicemail should yes no yes
27 Delete call log entries home could yes no
28 Expressive design (scalloped avatars grouped cards slide pill) design must yes no yes
29 Dynamic colour and dark mode design must yes no yes
30 Bluetooth audio route call should yes no 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
+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 = 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", "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,183 @@
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)
}
LaunchedEffect(Unit) {
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())
val call: (String) -> Unit = { n -> placeCall(ctx, n) }
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,120 @@
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)
if (call.details.state == Call.STATE_RINGING) showIncoming(call) else 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>) {
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
val person = Person.Builder().setName(name).build()
return NotificationCompat.Builder(this, CH_ONGOING).setSmallIcon(R.drawable.ic_stat_call)
.setContentTitle(name).setContentIntent(CallActivity.pending(this)).setOngoing(true).setSilent(true)
.setStyle(NotificationCompat.CallStyle.forOngoingCall(person, 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,59 @@
<?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="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="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("") }