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..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
@@ -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,9 @@ 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.core.content.edit
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
@@ -69,6 +78,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 +155,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) }
+ }
+}
+
+/**
+ * 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 +247,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 +269,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.