mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 08:12:26 +01:00
Phone in the car opens on recent calls, with missed calls marked, like Android Auto
This commit is contained in:
1 parent
564bf005c7
commit
cb1e6cd19d
3 files changed
+109
-8
No files matched your search
@@ -66,7 +66,8 @@
|
|||||||
|
|
||||||
<!--
|
<!--
|
||||||
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
|
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
|
||||||
call's own state (ringing, in progress) and the incoming number need; READ_CONTACTS
|
call's own state (ringing, in progress) and the incoming number need, and READ_CALL_LOG
|
||||||
|
also lists recent calls on the car's Phone screen, read on the phone only; READ_CONTACTS
|
||||||
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
|
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
|
||||||
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
|
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
|
||||||
and spam app too - an on-device consent, not a permission declared here.
|
and spam app too - an on-device consent, not a permission declared here.
|
||||||
|
|||||||
@@ -129,21 +129,61 @@ internal fun CallPromptOverlay() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val TAB_CONTACTS = 0
|
private const val TAB_RECENTS = 0
|
||||||
private const val TAB_KEYPAD = 1
|
private const val TAB_CONTACTS = 1
|
||||||
|
private const val TAB_KEYPAD = 2
|
||||||
|
|
||||||
/** Dialing a number or calling a contact from the car screen: a contacts tab and a keypad tab. */
|
/** One number from the call log: its latest call, whether that was missed, and when. */
|
||||||
|
private data class RecentCall(val contact: Contact, val missed: Boolean, val at: Long)
|
||||||
|
|
||||||
|
/** The latest call to or from each number, newest first, as Android Auto lists them. */
|
||||||
|
private fun loadRecents(context: Context): List<RecentCall>? {
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.READ_CALL_LOG) != PackageManager.PERMISSION_GRANTED) return null
|
||||||
|
val out = mutableListOf<RecentCall>()
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.query(
|
||||||
|
android.provider.CallLog.Calls.CONTENT_URI,
|
||||||
|
arrayOf(
|
||||||
|
android.provider.CallLog.Calls.NUMBER,
|
||||||
|
android.provider.CallLog.Calls.CACHED_NAME,
|
||||||
|
android.provider.CallLog.Calls.TYPE,
|
||||||
|
android.provider.CallLog.Calls.DATE,
|
||||||
|
),
|
||||||
|
null, null,
|
||||||
|
"${android.provider.CallLog.Calls.DATE} DESC",
|
||||||
|
)?.use { cursor ->
|
||||||
|
while (cursor.moveToNext() && out.size < RECENTS_SHOWN) {
|
||||||
|
val number = cursor.getString(0)?.takeIf { it.isNotBlank() } ?: continue
|
||||||
|
if (!seen.add(number)) continue
|
||||||
|
val name = cursor.getString(1)?.takeIf { it.isNotBlank() } ?: number
|
||||||
|
val type = cursor.getInt(2)
|
||||||
|
val missed = type == android.provider.CallLog.Calls.MISSED_TYPE || type == android.provider.CallLog.Calls.REJECTED_TYPE
|
||||||
|
out += RecentCall(Contact(name, number), missed, cursor.getLong(3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { GearslipLog.w("calls: reading the call log failed: ${it.message}") }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val RECENTS_SHOWN = 50
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calling from the car screen, laid out as Android Auto's dialer: recent calls first, since
|
||||||
|
* calling back is the usual reason to open it, then contacts and a keypad.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PhoneDialerScreen() {
|
fun PhoneDialerScreen() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) }
|
var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) }
|
||||||
var tab by remember { mutableStateOf(TAB_CONTACTS) }
|
var tab by remember { mutableStateOf(TAB_RECENTS) }
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
// The tabs share the title's row, so the list and keypad get the height back.
|
// The tabs share the title's row, so the list and keypad get the height back.
|
||||||
ScreenHeader(stringResource(R.string.carset_phone)) {
|
ScreenHeader(stringResource(R.string.carset_phone)) {
|
||||||
if (hasPermissions) {
|
if (hasPermissions) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
DialerTab(stringResource(R.string.dialer_recents), tab == TAB_RECENTS, Modifier.width(140.dp)) { tab = TAB_RECENTS }
|
||||||
DialerTab(stringResource(R.string.dialer_contacts), tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
DialerTab(stringResource(R.string.dialer_contacts), tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
||||||
DialerTab(stringResource(R.string.dialer_keypad), tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
|
DialerTab(stringResource(R.string.dialer_keypad), tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
|
||||||
}
|
}
|
||||||
@@ -156,7 +196,11 @@ fun PhoneDialerScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
if (tab == TAB_CONTACTS) ContactsList(context) else Box(Modifier.padding(bottom = 16.dp)) { Keypad(context) }
|
when (tab) {
|
||||||
|
TAB_RECENTS -> RecentsList(context)
|
||||||
|
TAB_CONTACTS -> ContactsList(context)
|
||||||
|
else -> Box(Modifier.padding(bottom = 16.dp)) { Keypad(context) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,6 +304,53 @@ private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecentsList(context: Context) {
|
||||||
|
// Null until read, then null again only when the call log isn't allowed.
|
||||||
|
var loaded by remember { mutableStateOf(false) }
|
||||||
|
val recents by produceState<List<RecentCall>?>(null, context) {
|
||||||
|
value = withContext(Dispatchers.IO) { loadRecents(context) }
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
var pendingCall by remember { mutableStateOf<Contact?>(null) }
|
||||||
|
val list = recents
|
||||||
|
|
||||||
|
when {
|
||||||
|
!loaded -> Unit
|
||||||
|
list == null -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.dialer_recents_needs_permission),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
GsButton(tone = GsTone.Tonal, onClick = {
|
||||||
|
app.seb3thehacker.gearslip.PermissionActivity.ask(context, arrayOf(Manifest.permission.READ_CALL_LOG))
|
||||||
|
}) { Text(stringResource(R.string.dialer_ask)) }
|
||||||
|
}
|
||||||
|
list.isEmpty() -> Text(
|
||||||
|
stringResource(R.string.dialer_no_recents),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
else -> LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(list) { call ->
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val ago = android.text.format.DateUtils.getRelativeTimeSpanString(call.at, now, android.text.format.DateUtils.MINUTE_IN_MILLIS).toString()
|
||||||
|
val detail = if (call.missed) stringResource(R.string.dialer_missed, ago) else ago
|
||||||
|
ContactRow(call.contact, detail = detail, alert = call.missed) { pendingCall = call.contact }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingCall?.let { contact ->
|
||||||
|
ConfirmCallOverlay(
|
||||||
|
contact = contact,
|
||||||
|
onConfirm = { placeCall(context, contact.number); pendingCall = null },
|
||||||
|
onDismiss = { pendingCall = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContactsList(context: Context) {
|
private fun ContactsList(context: Context) {
|
||||||
val contacts by produceState(emptyList<Contact>(), context) {
|
val contacts by produceState(emptyList<Contact>(), context) {
|
||||||
@@ -381,7 +472,7 @@ private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> U
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContactRow(contact: Contact, onClick: () -> Unit) {
|
private fun ContactRow(contact: Contact, detail: String = contact.number, alert: Boolean = false, onClick: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
@@ -406,7 +497,11 @@ private fun ContactRow(contact: Contact, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(
|
||||||
|
detail,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (alert) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Icon(Icons.Filled.Call, contentDescription = stringResource(R.string.dialer_call), tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.Filled.Call, contentDescription = stringResource(R.string.dialer_call), tint = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -666,11 +666,16 @@
|
|||||||
<string name="dash_all_day">All day</string>
|
<string name="dash_all_day">All day</string>
|
||||||
|
|
||||||
<!-- Phone dialer -->
|
<!-- Phone dialer -->
|
||||||
|
<string name="dialer_recents">Recents</string>
|
||||||
<string name="dialer_contacts">Contacts</string>
|
<string name="dialer_contacts">Contacts</string>
|
||||||
<string name="dialer_keypad">Keypad</string>
|
<string name="dialer_keypad">Keypad</string>
|
||||||
<string name="dialer_ask">Ask on the phone</string>
|
<string name="dialer_ask">Ask on the phone</string>
|
||||||
<string name="dialer_enter_number">Enter a number</string>
|
<string name="dialer_enter_number">Enter a number</string>
|
||||||
<string name="dialer_call">Call</string>
|
<string name="dialer_call">Call</string>
|
||||||
|
<string name="dialer_no_recents">No recent calls.</string>
|
||||||
|
<string name="dialer_recents_needs_permission">Gearslip needs the Call logs permission to list recent calls.</string>
|
||||||
|
<!-- %1$s is how long ago, such as "5 min. ago" -->
|
||||||
|
<string name="dialer_missed">Missed · %1$s</string>
|
||||||
<string name="dialer_no_contacts">No contacts with a phone number found.</string>
|
<string name="dialer_no_contacts">No contacts with a phone number found.</string>
|
||||||
<string name="dialer_search">Search contacts</string>
|
<string name="dialer_search">Search contacts</string>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user