diff --git a/README.md b/README.md index c51c4dc..8ff4b86 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ | 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 | | **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 | @@ -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. +### 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). + +

+ Start with favourites and call history + Incoming call + In call +

+ +Screenshots use made-up contacts. + ### 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. @@ -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. +## 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 [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 ``` +### Telefon + +[Get it on 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) + +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 ` ` (for example `Wetter 0.1.0`, tag `wetter-v0.1.0`) with one asset named `-.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). @@ -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 | | 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/). **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// recon, architecture, design system, build log, screenshots (one folder per app) 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) uhr/ the clock and alarm app (Kotlin, Jetpack Compose, AlarmManager, Glance, Room) ``` diff --git a/docs/screenshots/telefon/S01-home-light.png b/docs/screenshots/telefon/S01-home-light.png new file mode 100644 index 0000000..83c2e8f Binary files /dev/null and b/docs/screenshots/telefon/S01-home-light.png differ diff --git a/docs/screenshots/telefon/S06-incoming-light.png b/docs/screenshots/telefon/S06-incoming-light.png new file mode 100644 index 0000000..40abf41 Binary files /dev/null and b/docs/screenshots/telefon/S06-incoming-light.png differ diff --git a/docs/screenshots/telefon/S07-incall-dark.png b/docs/screenshots/telefon/S07-incall-dark.png new file mode 100644 index 0000000..66b0b5f Binary files /dev/null and b/docs/screenshots/telefon/S07-incall-dark.png differ diff --git a/replica/telefon/build-log.md b/replica/telefon/build-log.md new file mode 100644 index 0000000..2b12128 --- /dev/null +++ b/replica/telefon/build-log.md @@ -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. diff --git a/replica/telefon/clone-screens/S01-home-dark.png b/replica/telefon/clone-screens/S01-home-dark.png new file mode 100644 index 0000000..93e9d5b Binary files /dev/null and b/replica/telefon/clone-screens/S01-home-dark.png differ diff --git a/replica/telefon/clone-screens/S01-home-light.png b/replica/telefon/clone-screens/S01-home-light.png new file mode 100644 index 0000000..aa0dbdb Binary files /dev/null and b/replica/telefon/clone-screens/S01-home-light.png differ diff --git a/replica/telefon/clone-screens/S03-keypad-light.png b/replica/telefon/clone-screens/S03-keypad-light.png new file mode 100644 index 0000000..fa58a75 Binary files /dev/null and b/replica/telefon/clone-screens/S03-keypad-light.png differ diff --git a/replica/telefon/clone-screens/S06-incoming-light.png b/replica/telefon/clone-screens/S06-incoming-light.png new file mode 100644 index 0000000..4ae9610 Binary files /dev/null and b/replica/telefon/clone-screens/S06-incoming-light.png differ diff --git a/replica/telefon/clone-screens/S07-incall-dark.png b/replica/telefon/clone-screens/S07-incall-dark.png new file mode 100644 index 0000000..8470811 Binary files /dev/null and b/replica/telefon/clone-screens/S07-incall-dark.png differ diff --git a/replica/telefon/design/components.md b/replica/telefon/design/components.md index 2bae50b..94deb2f 100644 --- a/replica/telefon/design/components.md +++ b/replica/telefon/design/components.md @@ -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. 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. -``` diff --git a/replica/telefon/features.csv b/replica/telefon/features.csv index 4a7e8c9..da77d06 100644 --- a/replica/telefon/features.csv +++ b/replica/telefon/features.csv @@ -1,34 +1,34 @@ -feature,area,priority,original,clone,notes -Become the default phone app (role request),core,must,yes,no, -Call log grouped by day with direction icons,home,must,yes,no, -Call back from the log in one tap,home,must,yes,no, -Missed calls highlighted,home,must,yes,no, -Filter chips All Missed Contacts,home,should,yes,no, -Favourites row (starred contacts),home,must,yes,no, -Search contacts and numbers,search,must,yes,no, -Keypad with letters,keypad,must,yes,no, -T9 contact suggestions on the keypad,keypad,should,yes,no, -Place a call,call,must,yes,no, -Incoming call full screen with slide answer/decline,call,must,yes,no, -Incoming call notification with actions,call,must,yes,no, -In-call screen with timer,call,must,yes,no, -Mute,call,must,yes,no, -Speaker,call,must,yes,no, -In-call keypad (DTMF),call,must,yes,no, -Hold,call,should,yes,no, -Second incoming call (call waiting),call,should,yes,no, -Ongoing call notification,call,must,yes,no, -Proximity sensor screen off at the ear,call,must,yes,no, -Decline with quick reply SMS,call,should,yes,no, -Contact and number details with history,details,should,yes,no, -Toggle favourite,details,could,yes,no, -Open contact in contacts app / add contact,details,should,yes,no, -Call voicemail,voicemail,should,yes,no, -Delete call log entries,home,could,yes,no, -Expressive design (scalloped avatars grouped cards slide pill),design,must,yes,no, -Dynamic colour and dark mode,design,must,yes,no, -Bluetooth audio route,call,should,yes,no, -Spam detection and caller ID,home,could,yes,skip,Google database -Call screening and hold for me,call,could,yes,skip,Google AI -Call recording and live translation,call,could,yes,skip,Google AI; recording blocked for apps -Visual voicemail transcription,voicemail,could,yes,skip,carrier/Google +feature,area,priority,original,clone,notes +Become the default phone app (role request),core,must,yes,yes, +Call log grouped by day with direction icons,home,must,yes,yes, +Call back from the log in one tap,home,must,yes,yes, +Missed calls highlighted,home,must,yes,yes, +Filter chips All Missed Contacts,home,should,yes,yes, +Favourites row (starred contacts),home,must,yes,yes, +Search contacts and numbers,search,must,yes,yes, +Keypad with letters,keypad,must,yes,yes, +T9 contact suggestions on the keypad,keypad,should,yes,yes, +Place a call,call,must,yes,yes, +Incoming call full screen with slide answer/decline,call,must,yes,yes, +Incoming call notification with actions,call,must,yes,yes, +In-call screen with timer,call,must,yes,yes, +Mute,call,must,yes,yes, +Speaker,call,must,yes,yes, +In-call keypad (DTMF),call,must,yes,yes, +Hold,call,should,yes,yes, +Second incoming call (call waiting),call,should,yes,partial,the incoming screen appears over the active call; merging/swapping UI missing +Ongoing call notification,call,must,yes,yes, +Proximity sensor screen off at the ear,call,must,yes,yes, +Decline with quick reply SMS,call,should,yes,yes, +Contact and number details with history,details,should,yes,yes, +Toggle favourite,details,could,yes,yes, +Open contact in contacts app / add contact,details,should,yes,yes, +Call voicemail,voicemail,should,yes,yes, +Delete call log entries,home,could,yes,no, +Expressive design (scalloped avatars grouped cards slide pill),design,must,yes,yes, +Dynamic colour and dark mode,design,must,yes,yes, +Bluetooth audio route,call,should,yes,yes, +Spam detection and caller ID,home,could,yes,skip,Google database +Call screening and hold for me,call,could,yes,skip,Google AI +Call recording and live translation,call,could,yes,skip,Google AI; recording blocked for apps +Visual voicemail transcription,voicemail,could,yes,skip,carrier/Google diff --git a/telefon/.gitignore b/telefon/.gitignore new file mode 100644 index 0000000..77d60bd --- /dev/null +++ b/telefon/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +local.properties +.idea/ +*.iml +.kotlin/ diff --git a/telefon/app/build.gradle.kts b/telefon/app/build.gradle.kts new file mode 100644 index 0000000..e944d5e --- /dev/null +++ b/telefon/app/build.gradle.kts @@ -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")) } diff --git a/telefon/app/proguard-rules.pro b/telefon/app/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/telefon/app/src/main/AndroidManifest.xml b/telefon/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..79b8057 --- /dev/null +++ b/telefon/app/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/telefon/app/src/main/java/app/telefon/MainActivity.kt b/telefon/app/src/main/java/app/telefon/MainActivity.kt new file mode 100644 index 0000000..569507a --- /dev/null +++ b/telefon/app/src/main/java/app/telefon/MainActivity.kt @@ -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(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(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() diff --git a/telefon/app/src/main/java/app/telefon/call/CallActivity.kt b/telefon/app/src/main/java/app/telefon/call/CallActivity.kt new file mode 100644 index 0000000..1a21978 --- /dev/null +++ b/telefon/app/src/main/java/app/telefon/call/CallActivity.kt @@ -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) + } +} diff --git a/telefon/app/src/main/java/app/telefon/call/CallManager.kt b/telefon/app/src/main/java/app/telefon/call/CallManager.kt new file mode 100644 index 0000000..7e34dfd --- /dev/null +++ b/telefon/app/src/main/java/app/telefon/call/CallManager.kt @@ -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() + private val _state = MutableStateFlow>(emptyList()) + val state: StateFlow> = _state + private val _audio = MutableStateFlow(AudioUi()) + val audio: StateFlow = _audio + internal var service: CallService? = null + private val connectedAt = mutableMapOf() + + 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) +} diff --git a/telefon/app/src/main/java/app/telefon/call/CallService.kt b/telefon/app/src/main/java/app/telefon/call/CallService.kt new file mode 100644 index 0000000..3f5aebb --- /dev/null +++ b/telefon/app/src/main/java/app/telefon/call/CallService.kt @@ -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) { + 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) } + } + } +} diff --git a/telefon/app/src/main/java/app/telefon/data/Contacts.kt b/telefon/app/src/main/java/app/telefon/data/Contacts.kt new file mode 100644 index 0000000..85f4065 --- /dev/null +++ b/telefon/app/src/main/java/app/telefon/data/Contacts.kt @@ -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) +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 ContentResolver.observe(uri: Uri, load: () -> T): Flow = 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? { + 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> = context.contentResolver.observe(CallLog.Calls.CONTENT_URI) { loadLog(context) } + + private fun loadLog(context: Context): List { + if (!context.has(Manifest.permission.READ_CALL_LOG)) return emptyList() + val out = mutableListOf() + 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()) { 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> = context.contentResolver.observe(ContactsContract.Contacts.CONTENT_URI) { loadPeople(context) } + + private fun loadPeople(context: Context): List { + if (!context.has(Manifest.permission.READ_CONTACTS)) return emptyList() + val phones = mutableMapOf>() + 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() + 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) } + } +} diff --git a/telefon/app/src/main/java/app/telefon/ui/components/Avatar.kt b/telefon/app/src/main/java/app/telefon/ui/components/Avatar.kt new file mode 100644 index 0000000..5c66c28 --- /dev/null +++ b/telefon/app/src/main/java/app/telefon/ui/components/Avatar.kt @@ -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(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)) + } + } +} diff --git a/telefon/app/src/main/java/app/telefon/ui/components/Fmt.kt b/telefon/app/src/main/java/app/telefon/ui/components/Fmt.kt new file mode 100644 index 0000000..925ff68 --- /dev/null +++ b/telefon/app/src/main/java/app/telefon/ui/components/Fmt.kt @@ -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() +} diff --git a/telefon/app/src/main/java/app/telefon/ui/components/SettingsKit.kt b/telefon/app/src/main/java/app/telefon/ui/components/SettingsKit.kt new file mode 100644 index 0000000..41477f3 --- /dev/null +++ b/telefon/app/src/main/java/app/telefon/ui/components/SettingsKit.kt @@ -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 SettingsChoice(shape: Shape, title: String, options: List, 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) } +} diff --git a/telefon/app/src/main/java/app/telefon/ui/screens/Screens.kt b/telefon/app/src/main/java/app/telefon/ui/screens/Screens.kt new file mode 100644 index 0000000..323da4a --- /dev/null +++ b/telefon/app/src/main/java/app/telefon/ui/screens/Screens.kt @@ -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, people: List, 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, 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, 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, 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)) + } +} + diff --git a/telefon/app/src/main/java/app/telefon/ui/theme/Theme.kt b/telefon/app/src/main/java/app/telefon/ui/theme/Theme.kt new file mode 100644 index 0000000..e6cc6ed --- /dev/null +++ b/telefon/app/src/main/java/app/telefon/ui/theme/Theme.kt @@ -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) } +} diff --git a/telefon/app/src/main/java/app/telefon/ui/theme/Tokens.kt b/telefon/app/src/main/java/app/telefon/ui/theme/Tokens.kt new file mode 100644 index 0000000..fd5eb71 --- /dev/null +++ b/telefon/app/src/main/java/app/telefon/ui/theme/Tokens.kt @@ -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 +} diff --git a/telefon/app/src/main/res/drawable/ic_launcher_background.xml b/telefon/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d1f3fbc --- /dev/null +++ b/telefon/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1 @@ + diff --git a/telefon/app/src/main/res/drawable/ic_launcher_foreground.xml b/telefon/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..a665c33 --- /dev/null +++ b/telefon/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,4 @@ + + + + diff --git a/telefon/app/src/main/res/drawable/ic_launcher_monochrome.xml b/telefon/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..21a9a27 --- /dev/null +++ b/telefon/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,4 @@ + + + + diff --git a/telefon/app/src/main/res/drawable/ic_stat_call.xml b/telefon/app/src/main/res/drawable/ic_stat_call.xml new file mode 100644 index 0000000..1b3ac71 --- /dev/null +++ b/telefon/app/src/main/res/drawable/ic_stat_call.xml @@ -0,0 +1 @@ + diff --git a/telefon/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/telefon/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..80faec8 --- /dev/null +++ b/telefon/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/telefon/app/src/main/res/values/strings.xml b/telefon/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..bac295f --- /dev/null +++ b/telefon/app/src/main/res/values/strings.xml @@ -0,0 +1,59 @@ + + + Start + Wähltasten + Mailbox + Suchen + Kontakte durchsuchen + Zurück + Alle + Verpasst + Kontakte + Favoriten + Heute + Gestern + Noch keine Anrufe + Keine verpassten Anrufe + Keine Kontakte mit Telefonnummer + Unbekannt + Anrufen + %1$s anrufen + SMS + Favorit + Hinzufügen + In Kontakte öffnen + Anrufe + Löschen + Eingehend + Ausgehend + Verpasst + Abgelehnt + Blockiert + Mailbox + Deine Mailbox: %1$s + Für diese SIM-Karte ist keine Mailbox-Nummer hinterlegt. + Mailbox anrufen + Als Standard-Telefon-App festlegen + Nur dann kann diese App Anrufe annehmen und anzeigen. Du kannst das jederzeit in den Android-Einstellungen ändern. + + Eingehender Anruf + Wird angerufen … + Gehalten + Anruf beendet + Ablehnen + Annehmen + Nachricht + Ich kann gerade nicht sprechen. + Ich rufe dich gleich zurück. + Bin unterwegs, melde mich später. + Tasten + Stumm + Lautspr. + Mehr + Halten + Fortsetzen + Bluetooth + Auflegen + Eingehende Anrufe + Laufende Anrufe + diff --git a/telefon/app/src/test/java/app/telefon/ScreenshotTest.kt b/telefon/app/src/test/java/app/telefon/ScreenshotTest.kt new file mode 100644 index 0000000..0f9b9cf --- /dev/null +++ b/telefon/app/src/test/java/app/telefon/ScreenshotTest.kt @@ -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) } +} diff --git a/telefon/build.gradle.kts b/telefon/build.gradle.kts new file mode 100644 index 0000000..6b3c874 --- /dev/null +++ b/telefon/build.gradle.kts @@ -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 +} diff --git a/telefon/gradle.properties b/telefon/gradle.properties new file mode 100644 index 0000000..f97184b --- /dev/null +++ b/telefon/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/telefon/gradle/wrapper/gradle-wrapper.jar b/telefon/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/telefon/gradle/wrapper/gradle-wrapper.jar differ diff --git a/telefon/gradle/wrapper/gradle-wrapper.properties b/telefon/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/telefon/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/telefon/gradlew b/telefon/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/telefon/gradlew @@ -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" "$@" diff --git a/telefon/gradlew.bat b/telefon/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/telefon/gradlew.bat @@ -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 diff --git a/telefon/settings.gradle.kts b/telefon/settings.gradle.kts new file mode 100644 index 0000000..2dfa987 --- /dev/null +++ b/telefon/settings.gradle.kts @@ -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") diff --git a/uhr/app/build.gradle.kts b/uhr/app/build.gradle.kts index 3639ede..932d34d 100644 --- a/uhr/app/build.gradle.kts +++ b/uhr/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "app.uhr" minSdk = 29 targetSdk = 36 - versionCode = 4 - versionName = "0.2.0" + versionCode = 5 + versionName = "0.2.1" } // Release key lives outside the repo; without it, release builds fall back to the debug key (local only). diff --git a/uhr/app/src/main/AndroidManifest.xml b/uhr/app/src/main/AndroidManifest.xml index 3076593..0ac033c 100644 --- a/uhr/app/src/main/AndroidManifest.xml +++ b/uhr/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + + + + + + + + + + + + + + + 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), + ) +} diff --git a/uhr/app/src/main/java/app/uhr/MainActivity.kt b/uhr/app/src/main/java/app/uhr/MainActivity.kt index d694158..4f2b569 100644 --- a/uhr/app/src/main/java/app/uhr/MainActivity.kt +++ b/uhr/app/src/main/java/app/uhr/MainActivity.kt @@ -80,6 +80,7 @@ import java.time.LocalTime class MainActivity : ComponentActivity() { private var tabFromIntent = mutableIntStateOf(-1) + private val newAlarm = mutableStateOf(false) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -87,15 +88,17 @@ class MainActivity : ComponentActivity() { Graph.init(this) Fmt.is24h = DateFormat.is24HourFormat(this) tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1) + newAlarm.value = intent.getBooleanExtra(EXTRA_NEW_ALARM, false) setContent { 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) { super.onNewIntent(intent) tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1) + newAlarm.value = intent.getBooleanExtra(EXTRA_NEW_ALARM, false) } override fun onResume() { @@ -103,7 +106,10 @@ class MainActivity : ComponentActivity() { 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) @@ -118,7 +124,7 @@ private val TABS = listOf( @OptIn(ExperimentalMaterial3Api::class) @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 scope = rememberCoroutineScope() val context = LocalContext.current @@ -127,6 +133,7 @@ private fun ClockApp(settings: Settings, requestedTab: Int) { var showSettings by rememberSaveable { mutableStateOf(false) } var menu 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 keypad by rememberSaveable { mutableStateOf(false) } var digits by rememberSaveable { mutableStateOf("") }