From 5251a5d833ea4a06937d03d96049a3fc0a9ef557 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 20:20:29 +0000 Subject: [PATCH 1/2] feat(mail): Favoriten-Kachel ruft die Hauptnummer direkt an MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Tipp auf einen Kontakt-Favoriten auf Start ruft die Hauptnummer jetzt direkt an (ACTION_CALL). CALL_PHONE steht dafür im Manifest, Telefonie bleibt optional (android.hardware.telephony required=false), damit Tablets ohne Telefon die App weiter installieren. - Erster Tipp ohne Berechtigung: kurzer Hinweis im App-Design (mit dem Weg in den Android-Einstellungen), dann die System-Abfrage über den Activity-Result-Launcher. Erteilt: sofort anrufen; abgelehnt: diesmal der Wähler; „Nicht mehr fragen“: dauerhaft der Wähler ohne erneute Nachfrage. - Nummernprüfung: nur Ziffern, führendes +, * und # sowie Trennzeichen; alles andere öffnet den Kontakt. Übergabe per Uri.fromParts("tel", …). - Nummern mit * oder # (MMI/USSD wie *#06#, **21*…#, # am Ende) gehen nie direkt raus, immer nur in den Wähler. Geräte ohne Telefonie: Wähler. - „Anrufen“ in der Kontaktansicht, beim Absender und auf der Notfall-Karte bleibt beim Wähler. - Datenschutzerklärung (DE/EN, HTML) und Play-Notizen nennen die neue Berechtigung. - Unit-Tests für Nummernprüfung, Servicecodes, CALL/DIAL-Entscheidung, „Nicht mehr fragen“ und das Manifest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- app/src/main/AndroidManifest.xml | 7 ++ .../android/mail/MailContactLogic.kt | 62 +++++++++-- .../android/ui/mail/start/MailStart.kt | 103 ++++++++++++++++-- app/src/main/res/values-de/strings_pim2.xml | 4 + app/src/main/res/values/strings_pim2.xml | 4 + .../android/ManifestSecurityTest.kt | 9 ++ .../android/mail/MailContactLogicTest.kt | 69 +++++++++++- docs/GOOGLE_PLAY.md | 2 + docs/PRIVACY_POLICY.md | 2 + privacy-policy.html | 1 + 10 files changed, 243 insertions(+), 20 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c3b36fc8..1d4ad852 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,6 +44,11 @@ then). --> + + @@ -58,6 +63,8 @@ + + , val more: Int) { val empty: Boolean get() = days.isEmpty() } -/** What a tap on a favourite's tile does: call [tel] (a `tel:` uri) or, without a number, open the contact. */ +/** + * What a tap on a favourite's tile does: call [number] (checked by [MailContactLogic.callNumber]: digits, a leading + * `+`, `*` and `#`) or, without a usable number, open the contact. + */ sealed interface TileTap { - data class Call(val tel: String) : TileTap + data class Call(val number: String) : TileTap data class Open(val id: Long) : TileTap } +/** How a tile's call goes: [CALL] straight away, [DIAL] the dialer with the number, [ASK] the hint and Android's question first. */ +enum class CallStep { CALL, DIAL, ASK } + /** * The rules of the GC contact as the master (Mail 1.4.0, pure, unit-tested): which contacts the list shows and with * which dots, the hints of differing values, the preview of a merge, favourites and the main number, the tiles and @@ -201,14 +207,56 @@ object MailContactLogic { else -> pins.copy(contacts = (pins.contacts + pin).take(PINS_MAX)) } - /** A tap on a favourite's tile: its main number, else (none) the contact. */ - fun tap(pin: StartContactPin): TileTap = pin.phone?.let(MailPimLogic::telUri)?.let { TileTap.Call(it) } ?: TileTap.Open(pin.id) + /** A tap on a favourite's tile: its main number, else (none or not a number to call) the contact. */ + fun tap(pin: StartContactPin): TileTap = pin.phone?.let(::callNumber)?.let { TileTap.Call(it) } ?: TileTap.Open(pin.id) + + /** Separators a number may contain (left out of the number called). */ + private const val SEPARATORS = " \u00A0-./()" + + /** + * The number a tile calls, from a stored [raw] number: only digits, `+` (at the start), `*`, `#` and separators + * (space, `-`, `.`, `/`, brackets) are allowed, the separators are dropped. Anything else (letters, `,`/`;` pauses, + * a `+` inside) or fewer than 3 digits: null, the tile opens the contact. + */ + fun callNumber(raw: String): String? { + val t = raw.trim() + if (t.isEmpty() || t.length > 64) return null + if (t.any { !(it.isAsciiDigit() || it == '+' || it == '*' || it == '#' || it in SEPARATORS) }) return null + val n = t.filter { it !in SEPARATORS } + if (n.lastIndexOf('+') > 0) return null + return n.takeIf { c -> c.count { it.isAsciiDigit() } >= 3 } + } + + private fun Char.isAsciiDigit() = this in '0'..'9' + + /** + * Whether [number] looks like a service code (MMI/USSD: `*#06#`, `**21*…#`, `#31#…`, a `#` at the end): any + * `*` or `#`. Such a number is never called straight away, only put into the dialer. + */ + fun serviceCode(number: String): Boolean = number.any { it == '*' || it == '#' } + + /** + * How a tile calls [number]: straight away only with the permission to place calls (the person allowed it) and + * never a service code; else the dialer with the number (`ACTION_DIAL`, no permission needed). + */ + fun callAction(number: String, callPermission: Boolean): String = if (callPermission && !serviceCode(number)) ACTION_CALL else ACTION_DIAL + + /** + * The step for a tap on a tile calling [number]: a service code or a device without telephony always the dialer; + * with the permission the call; denied for good the dialer without asking again; else the hint and the question. + */ + fun callStep(number: String, telephony: Boolean, granted: Boolean, deniedForGood: Boolean): CallStep = when { + serviceCode(number) || !telephony -> CallStep.DIAL + granted -> CallStep.CALL + deniedForGood -> CallStep.DIAL + else -> CallStep.ASK + } /** - * How a tile calls: straight away (`ACTION_CALL`) only while the app holds the permission to place calls (the - * person allowed it), else the dialer with the number (`ACTION_DIAL`, no permission needed). + * Whether Android's answer means "don't ask again": denied, and Android showed an explanation before the question + * but would show none any more. A first denial leaves the explanation on, a dismissed question never had it. */ - fun callAction(callPermission: Boolean): String = if (callPermission) ACTION_CALL else ACTION_DIAL + fun deniedForGood(granted: Boolean, rationaleBefore: Boolean, rationaleAfter: Boolean): Boolean = !granted && rationaleBefore && !rationaleAfter /** The pins as kept in the offline copy (read without reflection). */ fun pinsJson(p: MailStartPins): JsonObject = JsonObject().apply { diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/start/MailStart.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/start/MailStart.kt index bf9f99f0..8521baa9 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/start/MailStart.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/start/MailStart.kt @@ -1,10 +1,15 @@ package com.gatecontrol.android.ui.mail.start import android.Manifest +import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -25,17 +30,20 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.AlertDialog 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.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment @@ -57,8 +65,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat -import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel @@ -69,6 +77,7 @@ import androidx.lifecycle.viewModelScope import com.gatecontrol.android.R import com.gatecontrol.android.layout.StartArrangement import com.gatecontrol.android.mail.AgendaView +import com.gatecontrol.android.mail.CallStep import com.gatecontrol.android.mail.MCalendars import com.gatecontrol.android.mail.MFavorite import com.gatecontrol.android.mail.MMe @@ -145,16 +154,91 @@ internal fun favoritesAvailable(me: MMe?, supported: Boolean?): Boolean = me?.ha // ─── favourite tiles ──────────────────────────────────────────────── -/** Calls [tel]: straight away while the app may place calls, else the dialer with the number. */ -internal fun dial(context: Context, tel: String) { - val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED - val intent = Intent(MailContactLogic.callAction(granted), tel.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) +/** Whether the person answered "don't ask again" to calling straight away (Android shows no question any more then). */ +private object DirectCallPrefs { + private const val PREFS = "mail_call" + private const val KEY_DENIED = "denied_for_good" + + fun deniedForGood(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_DENIED, false) + + fun setDeniedForGood(context: Context, on: Boolean) { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + if (prefs.getBoolean(KEY_DENIED, false) != on) prefs.edit().putBoolean(KEY_DENIED, on).apply() + } +} + +/** + * Starts the call of [number] with [action] (`ACTION_CALL` straight away, `ACTION_DIAL` the dialer). The number goes + * in as the parts of a `tel:` uri (`#` and `*` escaped), never parsed from a string. Refused: the dialer instead. + */ +internal fun startCall(context: Context, number: String, action: String) { + val intent = Intent(action, Uri.fromParts("tel", number, null)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) try { context.startActivity(intent) } catch (_: ActivityNotFoundException) { // no phone app: nothing to call with } catch (_: SecurityException) { - context.startActivity(Intent(MailContactLogic.ACTION_DIAL, tel.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + if (action != MailContactLogic.ACTION_DIAL) startCall(context, number, MailContactLogic.ACTION_DIAL) + } +} + +private tailrec fun Context.activity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.activity() + else -> null +} + +/** + * A favourite's call (the user wants a tap to call the main number straight away): with the permission to place calls + * the call, without it once a short hint and Android's question — allowed: the call, denied: this time the dialer, + * "don't ask again": from then on the dialer without asking. Service codes and devices without telephony: the dialer. + */ +@Composable +private fun rememberTileCall(): (String) -> Unit { + val context = LocalContext.current + var hint by rememberSaveable { mutableStateOf(null) } + var asking by rememberSaveable { mutableStateOf(null) } + var rationaleBefore by rememberSaveable { mutableStateOf(false) } + fun rationale() = context.activity()?.let { ActivityCompat.shouldShowRequestPermissionRationale(it, Manifest.permission.CALL_PHONE) } == true + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + val number = asking ?: return@rememberLauncherForActivityResult + asking = null + if (MailContactLogic.deniedForGood(granted, rationaleBefore, rationale())) DirectCallPrefs.setDeniedForGood(context, true) + startCall(context, number, MailContactLogic.callAction(number, granted)) + } + hint?.let { number -> + AlertDialog( + onDismissRequest = { hint = null }, + containerColor = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.extraLarge, + title = { Text(stringResource(R.string.fav_call_perm_title), style = MaterialTheme.typography.titleLarge) }, + text = { Text(stringResource(R.string.fav_call_perm_text), style = MaterialTheme.typography.bodyMedium) }, + confirmButton = { + TextButton(onClick = { + hint = null + rationaleBefore = rationale() + asking = number + launcher.launch(Manifest.permission.CALL_PHONE) + }) { Text(stringResource(R.string.fav_call_perm_continue), fontWeight = FontWeight.Bold) } + }, + dismissButton = { + TextButton(onClick = { + hint = null + startCall(context, number, MailContactLogic.ACTION_DIAL) + }) { Text(stringResource(R.string.fav_call_perm_dial)) } + }, + ) + } + return { number -> + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED + // Allowed later in Android's settings: a later withdrawal may ask again. + if (granted) DirectCallPrefs.setDeniedForGood(context, false) + val telephony = context.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) + when (MailContactLogic.callStep(number, telephony, granted, DirectCallPrefs.deniedForGood(context))) { + CallStep.CALL -> startCall(context, number, MailContactLogic.ACTION_CALL) + CallStep.DIAL -> startCall(context, number, MailContactLogic.ACTION_DIAL) + CallStep.ASK -> hint = number + } } } @@ -162,16 +246,17 @@ private val TileShape = RoundedCornerShape(18.dp) /** * A favourite on Start: the photo or the initials as the tile, the name below (like the plugins' buttons, [width]). - * A tap calls the main number (without one it opens the contact), a long press opens the contact. + * A tap calls the main number straight away (see [rememberTileCall]; without one it opens the contact), a long press + * opens the contact. */ @OptIn(ExperimentalFoundationApi::class) @Composable fun ContactTile(pin: StartContactPin, width: Dp) { val nav = LocalMailStartNav.current - val context = LocalContext.current val extra = GateControlTheme.extraColors val photo = rememberCardPhoto(pin.id.takeIf { pin.photoEtag != null }, pin.photoEtag) val tap = MailContactLogic.tap(pin) + val call = rememberTileCall() Column( Modifier .width(width) @@ -183,7 +268,7 @@ fun ContactTile(pin: StartContactPin, width: Dp) { onLongClick = { nav?.contact?.invoke(pin.id) }, onClick = { when (tap) { - is TileTap.Call -> dial(context, tap.tel) + is TileTap.Call -> call(tap.number) is TileTap.Open -> nav?.contact?.invoke(tap.id) } }, diff --git a/app/src/main/res/values-de/strings_pim2.xml b/app/src/main/res/values-de/strings_pim2.xml index cb198104..89515353 100644 --- a/app/src/main/res/values-de/strings_pim2.xml +++ b/app/src/main/res/values-de/strings_pim2.xml @@ -11,6 +11,10 @@ Von Start entfernt Markiere Kontakte mit dem Stern als Favoriten, um sie auf Start zu legen. Keine Nummer: ein Tipp öffnet den Kontakt + Direkt anrufen? + Damit ein Tipp direkt anruft, braucht GateControl die Berechtigung „Anrufe tätigen“. Ohne sie öffnet ein Tipp die Telefon-App mit der Nummer. Ändern kannst du das jederzeit in den Android-Einstellungen: Apps › GateControl › Berechtigungen › Telefon. + Weiter + Wähler öffnen %d abweichender Wert %d abweichende Werte diff --git a/app/src/main/res/values/strings_pim2.xml b/app/src/main/res/values/strings_pim2.xml index 7f9dbd81..9770516d 100644 --- a/app/src/main/res/values/strings_pim2.xml +++ b/app/src/main/res/values/strings_pim2.xml @@ -11,6 +11,10 @@ Removed from Start Mark contacts with the star as favourites to put them on Start. No number: a tap opens the contact + Call straight away? + So that a tap calls straight away, GateControl needs the permission “Make phone calls”. Without it a tap opens the phone app with the number. You can change this at any time in Android’s settings: Apps › GateControl › Permissions › Phone. + Continue + Open dialer %d different value %d different values diff --git a/app/src/test/java/com/gatecontrol/android/ManifestSecurityTest.kt b/app/src/test/java/com/gatecontrol/android/ManifestSecurityTest.kt index 5a8242e2..5320aab2 100644 --- a/app/src/test/java/com/gatecontrol/android/ManifestSecurityTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ManifestSecurityTest.kt @@ -180,6 +180,15 @@ class ManifestSecurityTest { assertTrue("android.permission.RECORD_AUDIO" in permissions) } + @Test + fun `favourites may call straight away, telephony stays optional for tablets`() { + val permissions = components("uses-permission").map { it.androidAttr("name") } + assertTrue("android.permission.CALL_PHONE" in permissions) + val telephony = components("uses-feature").firstOrNull { it.androidAttr("name") == "android.hardware.telephony" } + assertNotNull(telephony, "CALL_PHONE implies telephony: declare it optional") + assertEquals("false", telephony!!.androidAttr("required")) + } + @Test fun `chat bubble activity is not exported and can be embedded as a bubble`() { val bubble = components("activity").first { it.androidAttr("name").endsWith("ChatBubbleActivity") } diff --git a/app/src/test/java/com/gatecontrol/android/mail/MailContactLogicTest.kt b/app/src/test/java/com/gatecontrol/android/mail/MailContactLogicTest.kt index a0e6d56d..740fc45f 100644 --- a/app/src/test/java/com/gatecontrol/android/mail/MailContactLogicTest.kt +++ b/app/src/test/java/com/gatecontrol/android/mail/MailContactLogicTest.kt @@ -270,9 +270,11 @@ class MailContactLogicTest { val synced = MailContactLogic.syncedPins(pins, listOf(MFavorite(5, "Anna", "e", MCardPhone("0171 2", "mobile"), null), MFavorite(9, "Neu", null, null, null))) assertEquals(listOf(StartContactPin(5, "Anna", "e", "0171 2")), synced.contacts) assertTrue(synced.agenda) - assertEquals(TileTap.Call("tel:01712"), MailContactLogic.tap(synced.contacts[0])) + assertEquals(TileTap.Call("01712"), MailContactLogic.tap(synced.contacts[0])) assertEquals(TileTap.Open(6), MailContactLogic.tap(pins.contacts[1])) - assertEquals(TileTap.Call("tel:+4917112"), MailContactLogic.tap(StartContactPin(1, "x", null, "+49 171 12"))) + assertEquals(TileTap.Call("+4917112"), MailContactLogic.tap(StartContactPin(1, "x", null, "+49 171 12"))) + // not a number to call: the contact opens + assertEquals(TileTap.Open(1), MailContactLogic.tap(StartContactPin(1, "x", null, "0171 12 ext 3"))) val more = MailContactLogic.withPin(pins, StartContactPin(7, "C"), on = true) assertEquals(listOf(5L, 6L, 7L), more.extras.contacts) @@ -282,14 +284,73 @@ class MailContactLogicTest { assertEquals(12, MailContactLogic.withPin(full, StartContactPin(13, "x"), on = true).contacts.size) } + @Test + fun `calling - only digits, a leading plus, star, hash and separators`() { + assertEquals("+4917112345", MailContactLogic.callNumber(" +49 (171) 12-34/5 ")) + assertEquals("030123", MailContactLogic.callNumber("030.123")) + assertEquals("030123", MailContactLogic.callNumber("030\u00A0123")) + assertEquals("*31#0301234", MailContactLogic.callNumber("*31# 030 1234")) + // letters, pauses, a plus inside, too short, too long, empty: nothing to call + assertNull(MailContactLogic.callNumber("0171 12 ext 3")) + assertNull(MailContactLogic.callNumber("Tel. 0171 12")) + assertNull(MailContactLogic.callNumber("030 123,456")) + assertNull(MailContactLogic.callNumber("030 123;456")) + assertNull(MailContactLogic.callNumber("030+123")) + assertNull(MailContactLogic.callNumber("++49 171")) + assertNull(MailContactLogic.callNumber("*#06#")) + assertNull(MailContactLogic.callNumber("12")) + assertNull(MailContactLogic.callNumber(" ")) + assertNull(MailContactLogic.callNumber("1".repeat(65))) + assertNull(MailContactLogic.callNumber("017123")) + assertNull(MailContactLogic.callNumber("tel:0171 23")) + assertNull(MailContactLogic.callNumber("0171%2312")) + } + + @Test + fun `calling - service codes are never called straight away`() { + listOf("*#*#4636#*#*", "**21*0171234#", "#31#0301234", "*100#", "0301234#", "+49171*12").forEach { + val n = MailContactLogic.callNumber(it)!! + assertTrue(MailContactLogic.serviceCode(n), it) + assertEquals(MailContactLogic.ACTION_DIAL, MailContactLogic.callAction(n, callPermission = true), it) + assertEquals(CallStep.DIAL, MailContactLogic.callStep(n, telephony = true, granted = true, deniedForGood = false), it) + assertEquals(CallStep.DIAL, MailContactLogic.callStep(n, telephony = true, granted = false, deniedForGood = false), it) + } + assertFalse(MailContactLogic.serviceCode("+4917112345")) + } + @Test fun `calling - straight away only with the permission, else the dialer`() { - assertEquals(MailContactLogic.ACTION_DIAL, MailContactLogic.callAction(callPermission = false)) - assertEquals(MailContactLogic.ACTION_CALL, MailContactLogic.callAction(callPermission = true)) + assertEquals(MailContactLogic.ACTION_DIAL, MailContactLogic.callAction("0171234", callPermission = false)) + assertEquals(MailContactLogic.ACTION_CALL, MailContactLogic.callAction("0171234", callPermission = true)) assertEquals("android.intent.action.DIAL", MailContactLogic.ACTION_DIAL) assertEquals("android.intent.action.CALL", MailContactLogic.ACTION_CALL) } + @Test + fun `calling - the step of a tap - call, ask once, dialer when denied for good or without telephony`() { + val n = "+4917112345" + assertEquals(CallStep.CALL, MailContactLogic.callStep(n, telephony = true, granted = true, deniedForGood = false)) + assertEquals(CallStep.CALL, MailContactLogic.callStep(n, telephony = true, granted = true, deniedForGood = true)) + assertEquals(CallStep.ASK, MailContactLogic.callStep(n, telephony = true, granted = false, deniedForGood = false)) + assertEquals(CallStep.DIAL, MailContactLogic.callStep(n, telephony = true, granted = false, deniedForGood = true)) + // a tablet without a phone: never asks + assertEquals(CallStep.DIAL, MailContactLogic.callStep(n, telephony = false, granted = false, deniedForGood = false)) + assertEquals(CallStep.DIAL, MailContactLogic.callStep(n, telephony = false, granted = true, deniedForGood = false)) + } + + @Test + fun `calling - only a denial after an explanation without one afterwards is for good`() { + // allowed + assertFalse(MailContactLogic.deniedForGood(granted = true, rationaleBefore = true, rationaleAfter = false)) + // first denial: Android explains from now on + assertFalse(MailContactLogic.deniedForGood(granted = false, rationaleBefore = false, rationaleAfter = true)) + // the question dismissed (back): no denial + assertFalse(MailContactLogic.deniedForGood(granted = false, rationaleBefore = false, rationaleAfter = false)) + assertFalse(MailContactLogic.deniedForGood(granted = false, rationaleBefore = true, rationaleAfter = true)) + // denied again / "don't ask again" + assertTrue(MailContactLogic.deniedForGood(granted = false, rationaleBefore = true, rationaleAfter = false)) + } + @Test fun `pins are kept without reflection, broken entries left out`() { val pins = MailStartPins(listOf(StartContactPin(5, "Anna", "e1", "0171"), StartContactPin(6, "Ben")), agenda = true) diff --git a/docs/GOOGLE_PLAY.md b/docs/GOOGLE_PLAY.md index 08c3d7d0..b68f61e4 100644 --- a/docs/GOOGLE_PLAY.md +++ b/docs/GOOGLE_PLAY.md @@ -91,6 +91,8 @@ Für **jeden** erhobenen Datentyp gilt: nur erhoben, **nicht geteilt** (geht nur - *Warum im Hintergrund:* „Netzwechsel passieren, während die App geschlossen ist (Heimkommen, Verlassen der Wohnung). Ohne Hintergrund-Zugriff erkennt die App das vertrauenswürdige WLAN erst beim nächsten Öffnen.“ - *Video (30–60 s, Bildschirmaufnahme):* Einstellungen → Vertrauenswürdige WLANs → einschalten → „Aktuelles WLAN hinzufügen“ → Hinweisdialog zeigen → Standort „Bei Nutzung der App“ erlauben → Hinweis „Immer zulassen“ → in den Android-Einstellungen „Immer zulassen“ → App schließen → WLAN trennen/wechseln → VPN verbindet bzw. trennt sich automatisch (Benachrichtigung/Kachel zeigen). Als YouTube-Link (nicht gelistet) angeben. +**Anrufe tätigen** (`CALL_PHONE`, keine eingeschränkte Berechtigung, kein Formular): Ein Tipp auf einen Kontakt-Favoriten auf Start ruft dessen Hauptnummer direkt an. Gefragt erst beim ersten Tipp, mit einem Hinweis vor der System-Abfrage; abgelehnt öffnet sich die Telefon-App mit der Nummer. Servicecodes (mit `*` oder `#`) gehen nie direkt raus. Telefonie bleibt optional (`android.hardware.telephony` mit `required="false"`), Tablets ohne Telefon installieren die App weiter. Datensicherheit: keine neue Datenart (die App liest keine Anrufliste und sendet keine Nummern). + **VPN-Dienst** (Formular „VPN-Dienst“, falls abgefragt): - Kernfunktion der App ist ein VPN-Tunnel (WireGuard) zum eigenen GateControl-Server des Nutzers; kein Werbe-/Tracking-Zweck, keine Umleitung zu Dritten, keine Datenerhebung über den Tunnel. diff --git a/docs/PRIVACY_POLICY.md b/docs/PRIVACY_POLICY.md index 99df5f2d..3f792329 100644 --- a/docs/PRIVACY_POLICY.md +++ b/docs/PRIVACY_POLICY.md @@ -77,6 +77,7 @@ Nur zum Scannen des Einrichtungs-QR-Codes, wenn du den Scanner öffnest. Bilder | Akku-Optimierung ausnehmen | optional: Benachrichtigungen auch im Ruhezustand | | Standort (auch im Hintergrund) | optional: WLAN-Namen für vertrauenswürdige WLANs; Standort auf Tipp an den eigenen Server; Familie: Standort auf Anfrage der Eltern, „Bin angekommen“/SOS, Orts-Erinnerungen (siehe oben) | | Kamera | Einrichtungs-QR-Code | +| Anrufe tätigen (Telefon) | optional: Ein Tipp auf einen Kontakt-Favoriten auf Start ruft dessen Hauptnummer direkt an. Gefragt erst beim ersten Tipp; ohne die Berechtigung öffnet sich die Telefon-App mit der Nummer. Die App liest keine Anrufliste und keine Telefondaten und sendet nichts. Änderbar in den Android-Einstellungen: Apps › GateControl › Berechtigungen › Telefon | | Biometrie / Displaysperre | Tür & Kamera: Bestätigung vor dem Aufsperren und Öffnen einer Tür (über Android, nichts wird gespeichert oder gesendet) | ### Speicherung und Löschung @@ -164,6 +165,7 @@ Only to scan the setup QR code when you open the scanner. No images are stored o | Ignore battery optimisation | optional: notifications while the device sleeps | | Location (incl. background) | optional: Wi-Fi names for trusted Wi-Fi; location on tap to your own server; Family: location on a parent's request, "I've arrived"/SOS, place reminders (see above) | | Camera | Setup QR code | +| Make phone calls (Phone) | optional: a tap on a contact favourite on Start calls its main number straight away. Asked only on the first tap; without the permission the phone app opens with the number. The app reads no call log or phone data and sends nothing. Can be changed in Android's settings: Apps › GateControl › Permissions › Phone | | Biometrics / screen lock | Door & Camera: confirmation before a door is unlocked or opened (through Android; nothing is stored or sent) | ### Retention and deletion diff --git a/privacy-policy.html b/privacy-policy.html index 3ddadb82..9d3e4573 100644 --- a/privacy-policy.html +++ b/privacy-policy.html @@ -64,6 +64,7 @@

Permissions

  • VPN Service — Required by Android to create the WireGuard VPN tunnel
  • Boot Completed — Optional auto-connect on device startup (user-configurable)
  • Notifications — To display VPN connection status
  • +
  • CALL_PHONE — Optional: a tap on a contact favourite on Start calls its main number straight away. Asked only on the first tap; without it the phone app opens with the number. Service codes (with * or #) only ever go to the dialer. The app reads no call log or phone data and sends nothing.
  • From 39392b03dfe5b196c7c62e8a607b8c3482afda6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 20:36:51 +0000 Subject: [PATCH 2/2] =?UTF-8?q?refactor(mail):=20SharedPreferences=20?= =?UTF-8?q?=C3=BCber=20die=20KTX-Funktion=20edit=20schreiben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../java/com/gatecontrol/android/ui/mail/start/MailStart.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/start/MailStart.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/start/MailStart.kt index 8521baa9..8caad218 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/start/MailStart.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/start/MailStart.kt @@ -67,6 +67,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.content.edit import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel @@ -163,7 +164,7 @@ private object DirectCallPrefs { fun setDeniedForGood(context: Context, on: Boolean) { val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - if (prefs.getBoolean(KEY_DENIED, false) != on) prefs.edit().putBoolean(KEY_DENIED, on).apply() + if (prefs.getBoolean(KEY_DENIED, false) != on) prefs.edit { putBoolean(KEY_DENIED, on) } } }