feat: allow answering incoming calls

there is no in-call ui and likely won't be for a while.

even this basic implementation will probably break if you receive two calls that ring at the same time.
This commit is contained in:
Benjamin Wiegand
2026-06-24 18:44:25 -07:00
parent 3165a12f54
commit c18ee8bcce
12 changed files with 412 additions and 5 deletions
+3
View File
@@ -15,6 +15,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.READ_CALL_LOG" tools:ignore="SmsAndCallLogPolicy" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<!-- wireless connection -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
@@ -27,6 +27,7 @@ import io.benwiegand.projection.geargrinder.callback.MakeshiftBindCallback;
import io.benwiegand.projection.geargrinder.pm.AppRecord;
import io.benwiegand.projection.geargrinder.projection.ui.BatteryIndicator;
import io.benwiegand.projection.geargrinder.projection.ui.NotificationDisplay;
import io.benwiegand.projection.geargrinder.projection.ui.PhoneCallDisplay;
import io.benwiegand.projection.geargrinder.projection.ui.task.ProjectionTask;
import io.benwiegand.projection.geargrinder.projection.ui.NetworkIndicators;
import io.benwiegand.projection.geargrinder.projection.ui.task.ProjectionTaskManager;
@@ -55,6 +56,7 @@ public class ProjectionActivity extends AppCompatActivity implements MakeshiftBi
private BatteryIndicator batteryIndicator;
private NetworkIndicators networkIndicators;
private NotificationDisplay notificationDisplay;
private PhoneCallDisplay phoneCallDisplay;
private GeargrinderServiceConnector connector;
private IPrivd privd = null;
@@ -114,6 +116,7 @@ public class ProjectionActivity extends AppCompatActivity implements MakeshiftBi
batteryIndicator = new BatteryIndicator(findViewById(R.id.battery_indicator));
networkIndicators = new NetworkIndicators(findViewById(R.id.network_indicators));
notificationDisplay = new NotificationDisplay(findViewById(R.id.popup_notification_overlay));
phoneCallDisplay = new PhoneCallDisplay(this, findViewById(R.id.popup_incoming_call_overlay));
taskManager.registerListener(this);
@@ -155,6 +158,7 @@ public class ProjectionActivity extends AppCompatActivity implements MakeshiftBi
batteryIndicator.destroy();
networkIndicators.destroy();
notificationDisplay.destroy();
phoneCallDisplay.destroy();
}
private boolean focusSearch(int direction) {
@@ -45,10 +45,25 @@ public class PermissionRequirements {
settingsManager -> true);
public static final PermissionEntry READ_PHONE_STATE_PERMISSION_ENTRY = PermissionEntry.createForManifestPermission(
Manifest.permission.READ_PHONE_STATE, Build.VERSION_CODES.Q, Build.VERSION_CODES.R,
Manifest.permission.READ_PHONE_STATE, null, null,
R.string.read_phone_state_permission_title, R.string.read_phone_state_permission_rationale,
settingsManager -> true, settingsManager -> false);
public static final PermissionEntry ANSWER_PHONE_CALLS_PERMISSION_ENTRY = PermissionEntry.createForManifestPermission(
Manifest.permission.ANSWER_PHONE_CALLS, null, null,
R.string.answer_phone_calls_permission_title, R.string.answer_phone_calls_permission_rationale,
settingsManager -> true, settingsManager -> false);
public static final PermissionEntry READ_CALL_LOG_PERMISSION_ENTRY = PermissionEntry.createForManifestPermission(
Manifest.permission.READ_CALL_LOG, null, null,
R.string.read_call_log_permission_title, R.string.read_call_log_permission_rationale,
settingsManager -> true, settingsManager -> false);
public static final PermissionEntry READ_CONTACTS_PERMISSION_ENTRY = PermissionEntry.createForManifestPermission(
Manifest.permission.READ_CONTACTS, null, null,
R.string.read_contacts_permission_title, R.string.read_contacts_permission_rationale,
settingsManager -> true, settingsManager -> false);
@SuppressLint("InlinedApi")
public static final PermissionEntry BLUETOOTH_CONNECT_PERMISSION_ENTRY = PermissionEntry.createForManifestPermission(
Manifest.permission.BLUETOOTH_CONNECT, Build.VERSION_CODES.S, null,
@@ -161,8 +176,11 @@ public class PermissionRequirements {
POST_NOTIFICATIONS_PERMISSION_ENTRY,
RECORD_AUDIO_PERMISSION_ENTRY,
NOTIFICATION_LISTENER_PERMISSION_ENTRY,
READ_PHONE_STATE_PERMISSION_ENTRY,
ANSWER_PHONE_CALLS_PERMISSION_ENTRY,
READ_CALL_LOG_PERMISSION_ENTRY,
READ_CONTACTS_PERMISSION_ENTRY,
BLUETOOTH_CONNECT_PERMISSION_ENTRY,
ACCESS_FINE_LOCATION_PERMISSION_ENTRY,
READ_PHONE_STATE_PERMISSION_ENTRY
ACCESS_FINE_LOCATION_PERMISSION_ENTRY
);
}
@@ -0,0 +1,240 @@
package io.benwiegand.projection.geargrinder.projection.ui;
import android.Manifest;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.provider.ContactsContract;
import android.telecom.TelecomManager;
import android.telephony.TelephonyManager;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.app.ActivityCompat;
import java.io.InputStream;
import io.benwiegand.projection.geargrinder.R;
public class PhoneCallDisplay {
private static final String TAG = PhoneCallDisplay.class.getSimpleName();
private static final long INCOMING_CALL_OVERLAY_ANIMATION_DURATION = 200;
private record CallerId(String phoneNumber, String displayName, Uri photoUri, Uri photoThumbnailUri) {
public CallerId(String phoneNumber) {
this(phoneNumber, null, null, null);
}
public CallerId() {
this(null, null, null, null);
}
public String friendlyName(Context context) {
if (displayName() != null) return displayName();
if (phoneNumber() != null) return phoneNumber();
return context.getString(R.string.unknown_caller);
}
public Drawable thumbnailDrawable(Context context) {
try {
Uri uri = photoThumbnailUri() != null ? photoThumbnailUri() : photoUri();
if (uri != null) {
InputStream is = context.getContentResolver().openInputStream(uri);
return Drawable.createFromStream(is, null);
}
} catch (Throwable t) {
Log.e(TAG, "failed to load contact photo", t);
}
return AppCompatResources.getDrawable(context, R.drawable.ic_launcher_foreground); // TODO
}
}
private static int callStateStringToInt(String callStateStr) {
if (TelephonyManager.EXTRA_STATE_IDLE.equals(callStateStr)) return TelephonyManager.CALL_STATE_IDLE;
if (TelephonyManager.EXTRA_STATE_RINGING.equals(callStateStr)) return TelephonyManager.CALL_STATE_RINGING;
if (TelephonyManager.EXTRA_STATE_OFFHOOK.equals(callStateStr)) return TelephonyManager.CALL_STATE_OFFHOOK;
Log.wtf(TAG, "unknown call state: " + callStateStr);
return -1;
}
private final Context context;
private final ViewGroup popupIncomingCallOverlay;
private final TelephonyManager telephonyManager;
private final TelecomManager telecomManager;
private int callState = TelephonyManager.CALL_STATE_IDLE;
private CallerId callerId = new CallerId();
private final BroadcastReceiver phoneStateReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
Log.d(TAG, "phone state update: " + intent);
Log.d(TAG, "extras: " + intent.getExtras());
if (intent.getExtras() != null) {
for (String key : intent.getExtras().keySet()) {
Log.v(TAG, "- " + key + " = " + intent.getStringExtra(key));
}
}
if (!TelephonyManager.ACTION_PHONE_STATE_CHANGED.equals(intent.getAction())) return;
String phoneNumber = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER);
int newCallState = callStateStringToInt(intent.getStringExtra(TelephonyManager.EXTRA_STATE));
if (newCallState == -1) {
newCallState = telephonyManager.getCallState();
Log.w(TAG, "using call state from telephony manager: " + newCallState);
}
boolean callerIdUpdated = false;
if (callState == TelephonyManager.CALL_STATE_IDLE) {
callerIdUpdated = true;
callerId = new CallerId();
}
if (phoneNumber != null && !phoneNumber.equals(callerId.phoneNumber())) {
callerIdUpdated = true;
callerId = queryCallerId(phoneNumber);
}
if (callerIdUpdated) onCallerIdUpdated();
callState = newCallState;
switch (callState) {
case TelephonyManager.CALL_STATE_IDLE -> onCallIdle();
case TelephonyManager.CALL_STATE_RINGING -> onCallRinging();
case TelephonyManager.CALL_STATE_OFFHOOK -> onCallOffHook();
default -> Log.wtf(TAG, "unhandled call state: " + callState, new AssertionError());
}
}
};
public PhoneCallDisplay(Context context, ViewGroup popupIncomingCallOverlay) {
this.context = context;
this.popupIncomingCallOverlay = popupIncomingCallOverlay;
telephonyManager = context.getSystemService(TelephonyManager.class);
telecomManager = context.getSystemService(TelecomManager.class);
inflateIncomingCallPopup();
context.registerReceiver(phoneStateReceiver, new IntentFilter(TelephonyManager.ACTION_PHONE_STATE_CHANGED));
}
public void destroy() {
context.unregisterReceiver(phoneStateReceiver);
}
private void animateIncomingCallOverlay(boolean show) {
popupIncomingCallOverlay.animate()
.setStartDelay(0)
.setDuration(INCOMING_CALL_OVERLAY_ANIMATION_DURATION)
.withStartAction(() -> { if (show) popupIncomingCallOverlay.setVisibility(View.VISIBLE); })
.withEndAction(() -> { if (!show) popupIncomingCallOverlay.setVisibility(View.GONE); })
.alpha(show ? 1 : 0)
.translationX(show ? 0 : popupIncomingCallOverlay.getWidth());
}
private void onCallerIdUpdated() {
TextView cidText = popupIncomingCallOverlay.findViewById(R.id.caller_id_text);
ImageView cidImage = popupIncomingCallOverlay.findViewById(R.id.caller_id_image);
cidText.setText(callerId.friendlyName(context));
cidImage.setImageDrawable(callerId.thumbnailDrawable(context));
}
private void onCallRinging() {
animateIncomingCallOverlay(true);
}
private void onCallOffHook() {
animateIncomingCallOverlay(false);
}
private void onCallIdle() {
animateIncomingCallOverlay(false);
}
private CallerId queryCallerId(String phoneNumber) {
Log.d(TAG, "finding contact for " + phoneNumber);
String[] projection = new String[] {
ContactsContract.PhoneLookup._ID,
ContactsContract.PhoneLookup.DISPLAY_NAME_PRIMARY,
ContactsContract.PhoneLookup.PHOTO_URI,
ContactsContract.PhoneLookup.PHOTO_THUMBNAIL_URI,
};
try (Cursor cur = context.getContentResolver().query(Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(phoneNumber)), projection, null, null, null)) {
if (cur == null || !cur.moveToFirst()) {
Log.e(TAG, "contact lookup failed: " + (cur == null ? "null result" : "no results"));
return new CallerId(phoneNumber);
}
String displayName = cur.getString(1);
String photoUri = cur.getString(2);
String photoThumbUri = cur.getString(3);
return new CallerId(
phoneNumber, displayName,
photoUri != null ? Uri.parse(photoUri) : null,
photoThumbUri != null ? Uri.parse(photoThumbUri) : null
);
} catch (Throwable t) {
Log.e(TAG, "contact lookup threw", t);
}
return new CallerId(phoneNumber);
}
private void inflateIncomingCallPopup() {
View acceptCallButton = popupIncomingCallOverlay.findViewById(R.id.accept_call_button);
View rejectCallButton = popupIncomingCallOverlay.findViewById(R.id.reject_call_button);
View fallbackNotice = popupIncomingCallOverlay.findViewById(R.id.incoming_call_fallback_notice);
Runnable showFallbackNotice = () -> {
acceptCallButton.setVisibility(View.GONE);
fallbackNotice.setVisibility(View.VISIBLE);
};
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED)
showFallbackNotice.run();
acceptCallButton.setOnClickListener(v -> {
try {
telecomManager.acceptRingingCall();
} catch (Throwable t) {
Log.wtf(TAG, "failed to accept call", t);
showFallbackNotice.run();
}
});
rejectCallButton.setOnClickListener(v -> {
// this doesn't work since if there's an active call and another incoming call it'll hang up the active call
// try {
// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return;
// telecomManager.endCall();
// } catch (Throwable t) {
// Log.wtf(TAG, "failed to reject call", t);
// showFallbackNotice.run();
// }
// unfortunately not much I can do here, USB doesn't count as a "companion device".
// and figuring out how to do it via privd is a rabbit hole that I don't have the time for currently.
// perhaps it could be doable with InCallService for wireless connections, but I don't actually use a wireless connection.
animateIncomingCallOverlay(false);
});
}
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="10dp" />
<solid android:color="?attr/colorSurfaceContainer" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient android:startColor="#aa000000" android:endColor="#20000000" android:angle="180" />
</shape>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent">
@@ -139,7 +140,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:background="@drawable/popup_notification_overlay_background"
android:background="@drawable/popup_top_overlay_background"
android:alpha="0"
android:visibility="gone"
>
@@ -160,4 +161,31 @@
</LinearLayout>
<FrameLayout
android:id="@+id/popup_incoming_call_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="48dp"
android:background="@drawable/popup_right_overlay_background"
android:alpha="0"
android:visibility="gone"
android:clickable="true"
>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="right|bottom"
android:padding="12dp"
tools:ignore="RtlHardcoded"
>
<include
layout="@layout/layout_incoming_call_popup"
/>
</FrameLayout>
</FrameLayout>
</FrameLayout>
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="12dp"
android:background="@drawable/popup_floating_background"
android:orientation="vertical"
android:gravity="center"
android:elevation="8dp"
android:clickable="true"
>
<ImageView
android:id="@+id/caller_id_image"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_weight="9"
android:importantForAccessibility="no"
tools:src="@android:drawable/ic_menu_gallery"
/>
<TextView
android:id="@+id/caller_id_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:padding="12dp"
android:textAlignment="center"
android:ellipsize="end"
android:maxLines="2"
style="?textAppearanceHeadline5"
tools:text="some caller"
/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="0"
android:orientation="horizontal"
android:padding="12dp"
>
<ImageButton
android:id="@+id/accept_call_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_weight="0"
android:background="@drawable/circle"
android:backgroundTint="?colorAcceptCallButton"
android:src="@android:drawable/stat_sys_phone_call"
style="?imageButtonStyle"
/>
<Space
android:layout_width="48dp"
android:layout_height="0dp"
android:layout_weight="1"
/>
<ImageButton
android:id="@+id/reject_call_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_weight="0"
android:background="@drawable/circle"
android:backgroundTint="?colorRejectCallButton"
android:src="@android:drawable/ic_menu_close_clear_cancel"
style="?imageButtonStyle"
/>
</LinearLayout>
<TextView
android:id="@+id/incoming_call_fallback_notice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center"
android:text="@string/incoming_call_fallback_notice"
android:visibility="gone"
style="?textAppearanceBody1"
tools:visibility="visible"
/>
</LinearLayout>
+3
View File
@@ -17,6 +17,9 @@
<attr name="colorFocusIndicatorInactive" format="color" />
<attr name="colorFocusIndicatorActive" format="color" />
<attr name="colorAcceptCallButton" format="color" />
<attr name="colorRejectCallButton" format="color" />
</declare-styleable>
</resources>
+10 -1
View File
@@ -58,9 +58,15 @@
<string name="post_notifications_permission_title">Post notifications</string>
<string name="post_notifications_permission_rationale">Allows Flywheel to run in the background, communicate errors, and show connection status. Things may not work properly without this permission.</string>
<string name="read_phone_state_permission_title">Read phone state</string>
<string name="read_phone_state_permission_rationale">Allows Flywheel to display more accurate telephone signal status on Android 11 and below.\n\nThis permission is optional.</string>
<string name="read_phone_state_permission_rationale">Allows Flywheel to display incoming calls on your car.\n\nOn Android 11 and below, it is also used to show more accurate cell signal status.</string>
<string name="record_audio_permission_title">Record audio</string>
<string name="record_audio_permission_rationale">Allows Flywheel to capture device audio via screen sharing for playback through your car.</string>
<string name="answer_phone_calls_permission_title">Answer phone calls</string>
<string name="answer_phone_calls_permission_rationale">Allows Flywheel to show a button to accept incoming calls.</string>
<string name="read_call_log_permission_title">Read call log</string>
<string name="read_call_log_permission_rationale">Allows Flywheel to show the phone number or name for incoming calls.</string>
<string name="read_contacts_permission_title">Read contacts</string>
<string name="read_contacts_permission_rationale">Allows Flywheel to show the contact photo and display name for incoming calls.</string>
<string name="bluetooth_connect_permission_title">Bluetooth connection</string>
<string name="bluetooth_connect_permission_rationale">Allows Flywheel to connect to your car over Bluetooth, which is needed to start a wireless connection.\n\nThis is permission only needed if you want to connect wirelessly.</string>
<string name="access_fine_location_permission_title">Fine location</string>
@@ -177,4 +183,7 @@
<string name="phone_audio_capture_name">Capture call audio (requires root or Shizuku)</string>
<string name="phone_audio_capture_summary">play phone call audio through your car</string>
<string name="unknown_caller">Unknown</string>
<string name="incoming_call_fallback_notice">when it is safe, answer on your phone</string>
</resources>
+3
View File
@@ -9,6 +9,9 @@
<item name="colorAppModal">?attr/colorSurfaceContainer</item>
<item name="colorAcceptCallButton">#23A023</item>
<item name="colorRejectCallButton">#9E3434</item>
<item name="dynamicColorThemeOverlay">@style/ThemeOverlay.Geargrinder.DynamicColors</item>
<item name="actionBarStyle">@style/ThemeOverlay.Geargrinder.ActionBar</item>
<item name="alertDialogTheme">@style/ThemeOverlay.Geargrinder.AlertDialog</item>