Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@
then). -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Mail favourites on Start: a tap on a favourite's tile calls its main
number straight away. Asked on the first tap (with a hint before
Android's question); without it the tile opens the dialer. Service
codes (* or #) only ever go to the dialer. -->
<uses-permission android:name="android.permission.CALL_PHONE" />

<!-- Query all launchable apps for split-tunnel app picker -->
<queries>
Expand All @@ -58,6 +63,8 @@
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<!-- CALL_PHONE implies telephony: tablets without a phone keep installing the app. -->
<uses-feature android:name="android.hardware.telephony" android:required="false" />

<application
android:name=".GateControlApp"
Expand Down
62 changes: 55 additions & 7 deletions app/src/main/java/com/gatecontrol/android/mail/MailContactLogic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,18 @@ data class AgendaView(val days: List<AgendaDay>, 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
Expand Down Expand Up @@ -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 {
Expand Down
104 changes: 95 additions & 9 deletions app/src/main/java/com/gatecontrol/android/ui/mail/start/MailStart.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -145,33 +155,109 @@ 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<String?>(null) }
var asking by rememberSaveable { mutableStateOf<String?>(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
}
}
}

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)
Expand All @@ -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)
}
},
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values-de/strings_pim2.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
<string name="fav_unpinned">Von Start entfernt</string>
<string name="fav_none_hint">Markiere Kontakte mit dem Stern als Favoriten, um sie auf Start zu legen.</string>
<string name="fav_no_phone">Keine Nummer: ein Tipp öffnet den Kontakt</string>
<string name="fav_call_perm_title">Direkt anrufen?</string>
<string name="fav_call_perm_text">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.</string>
<string name="fav_call_perm_continue">Weiter</string>
<string name="fav_call_perm_dial">Wähler öffnen</string>
<plurals name="con_conflicts_n">
<item quantity="one">%d abweichender Wert</item>
<item quantity="other">%d abweichende Werte</item>
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings_pim2.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
<string name="fav_unpinned">Removed from Start</string>
<string name="fav_none_hint">Mark contacts with the star as favourites to put them on Start.</string>
<string name="fav_no_phone">No number: a tap opens the contact</string>
<string name="fav_call_perm_title">Call straight away?</string>
<string name="fav_call_perm_text">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.</string>
<string name="fav_call_perm_continue">Continue</string>
<string name="fav_call_perm_dial">Open dialer</string>
<plurals name="con_conflicts_n">
<item quantity="one">%d different value</item>
<item quantity="other">%d different values</item>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down
Loading
Loading