Skip to content

Commit 7541455

Browse files
Wear OS: implement Phone call handling and media controls
1 parent 5686ca7 commit 7541455

File tree

5 files changed

+691
-0
lines changed

5 files changed

+691
-0
lines changed

play-services-core/src/main/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@
142142
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
143143
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
144144
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
145+
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
146+
<uses-permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
145147

146148
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
147149
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.wearable;
7+
8+
import android.content.Context;
9+
import android.os.Build;
10+
import android.telecom.TelecomManager;
11+
import android.telephony.PhoneStateListener;
12+
import android.telephony.TelephonyManager;
13+
import android.util.Log;
14+
15+
import java.io.ByteArrayInputStream;
16+
import java.io.ByteArrayOutputStream;
17+
import java.io.DataInputStream;
18+
import java.io.DataOutputStream;
19+
import java.lang.ref.WeakReference;
20+
21+
/**
22+
* Bridges phone call state between the Android phone and connected Wear OS peers.
23+
*
24+
* <p>Responsibilities:
25+
* <ul>
26+
* <li>Registers a {@link PhoneStateListener} to monitor ringing / off-hook / idle state.
27+
* <li>Sends call-state updates to all connected watch nodes via
28+
* {@link WearableImpl#sendMessage} on path {@value #PHONE_PATH}.
29+
* <li>Handles call-control commands arriving from the watch on path
30+
* {@value #PHONE_COMMAND_PATH}: answer, reject/end, and silence-ringer.
31+
* </ul>
32+
*
33+
* <h3>Phone-state payload format (path {@value #PHONE_PATH})</h3>
34+
* <pre>
35+
* byte type 0 = idle, 1 = ringing, 2 = off-hook
36+
* UTF phoneNumber (empty string when unavailable)
37+
* UTF contactName (empty string when unavailable)
38+
* </pre>
39+
*
40+
* <h3>Command payload format (path {@value #PHONE_COMMAND_PATH})</h3>
41+
* <pre>
42+
* byte command 1 = answer, 2 = reject/end, 3 = silence ringer
43+
* </pre>
44+
*/
45+
public class CallBridge {
46+
47+
private static final String TAG = "GmsWearCallBridge";
48+
49+
/** Path used to push phone-state updates to connected wearable peers. */
50+
public static final String PHONE_PATH = "/wearable/phone";
51+
52+
/** Path on which the wearable peer sends call-control commands. */
53+
public static final String PHONE_COMMAND_PATH = "/wearable/phone/command";
54+
55+
// Phone-state type constants (sent in the payload to the watch)
56+
private static final byte STATE_IDLE = 0;
57+
private static final byte STATE_RINGING = 1;
58+
private static final byte STATE_OFFHOOK = 2;
59+
60+
// Command constants (received from the watch)
61+
private static final byte CMD_ANSWER = 1;
62+
private static final byte CMD_END = 2;
63+
private static final byte CMD_SILENCE = 3;
64+
65+
private static PhoneStateListener sPhoneStateListener;
66+
67+
// -------------------------------------------------------------------------
68+
// Lifecycle
69+
// -------------------------------------------------------------------------
70+
71+
/**
72+
* Registers a {@link PhoneStateListener} and begins forwarding call-state changes
73+
* to all connected wearable nodes.
74+
*
75+
* <p>Safe to call multiple times; subsequent calls replace the previous listener.
76+
*/
77+
public static synchronized void start(Context context, WearableImpl wearable) {
78+
stop(context);
79+
TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
80+
if (tm == null) {
81+
Log.w(TAG, "TelephonyManager unavailable, call monitoring disabled");
82+
return;
83+
}
84+
sPhoneStateListener = new WearPhoneStateListener(context, wearable);
85+
tm.listen(sPhoneStateListener, PhoneStateListener.LISTEN_CALL_STATE);
86+
Log.d(TAG, "call state listener registered");
87+
}
88+
89+
/**
90+
* Unregisters the previously registered {@link PhoneStateListener}.
91+
*/
92+
public static synchronized void stop(Context context) {
93+
if (sPhoneStateListener == null) return;
94+
TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
95+
if (tm != null) {
96+
tm.listen(sPhoneStateListener, PhoneStateListener.LISTEN_NONE);
97+
}
98+
sPhoneStateListener = null;
99+
Log.d(TAG, "call state listener unregistered");
100+
}
101+
102+
// -------------------------------------------------------------------------
103+
// Call-control actions (invoked by WearableServiceImpl or command handler)
104+
// -------------------------------------------------------------------------
105+
106+
/**
107+
* Answers the current ringing call using {@link TelecomManager}.
108+
* Requires {@code android.permission.ANSWER_PHONE_CALLS} on API 26+.
109+
*/
110+
public static void answerCall(Context context) {
111+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
112+
TelecomManager tm = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE);
113+
if (tm != null) {
114+
try {
115+
tm.acceptRingingCall();
116+
Log.d(TAG, "acceptRingingCall() called");
117+
} catch (SecurityException e) {
118+
Log.w(TAG, "Missing ANSWER_PHONE_CALLS permission", e);
119+
}
120+
}
121+
} else {
122+
Log.d(TAG, "answerCall: requires API 26+, ignoring");
123+
}
124+
}
125+
126+
/**
127+
* Ends the current call (or rejects an incoming one) using {@link TelecomManager}.
128+
* Requires {@code android.permission.ANSWER_PHONE_CALLS} on API 28+ for
129+
* ending an active call.
130+
*/
131+
public static void endCall(Context context) {
132+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
133+
TelecomManager tm = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE);
134+
if (tm != null) {
135+
try {
136+
tm.endCall();
137+
Log.d(TAG, "endCall() called");
138+
} catch (SecurityException e) {
139+
Log.w(TAG, "Missing ANSWER_PHONE_CALLS permission for endCall", e);
140+
}
141+
}
142+
} else {
143+
Log.d(TAG, "endCall: requires API 28+, ignoring");
144+
}
145+
}
146+
147+
/**
148+
* Silences the ringer for the current incoming call without rejecting it.
149+
* Uses {@link TelecomManager#silenceRinger()} on API 23+.
150+
*/
151+
public static void silenceRinger(Context context) {
152+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
153+
TelecomManager tm = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE);
154+
if (tm != null) {
155+
try {
156+
tm.silenceRinger();
157+
Log.d(TAG, "silenceRinger() called");
158+
} catch (SecurityException e) {
159+
Log.w(TAG, "Missing permission for silenceRinger", e);
160+
}
161+
}
162+
} else {
163+
Log.d(TAG, "silenceRinger: requires API 23+, ignoring");
164+
}
165+
}
166+
167+
// -------------------------------------------------------------------------
168+
// Incoming command handling
169+
// -------------------------------------------------------------------------
170+
171+
/**
172+
* Dispatches a call-control command received from the watch.
173+
*
174+
* @param context application context
175+
* @param data raw payload bytes from the watch message
176+
*/
177+
public static void handleCommand(Context context, byte[] data) {
178+
if (data == null || data.length == 0) {
179+
Log.w(TAG, "handleCommand: empty payload");
180+
return;
181+
}
182+
try {
183+
DataInputStream dis = new DataInputStream(new ByteArrayInputStream(data));
184+
byte command = dis.readByte();
185+
Log.d(TAG, "handleCommand: command=" + command);
186+
switch (command) {
187+
case CMD_ANSWER:
188+
answerCall(context);
189+
break;
190+
case CMD_END:
191+
endCall(context);
192+
break;
193+
case CMD_SILENCE:
194+
silenceRinger(context);
195+
break;
196+
default:
197+
Log.w(TAG, "handleCommand: unknown command=" + command);
198+
}
199+
} catch (Exception e) {
200+
Log.e(TAG, "handleCommand: failed to parse payload", e);
201+
}
202+
}
203+
204+
// -------------------------------------------------------------------------
205+
// Encoding helpers
206+
// -------------------------------------------------------------------------
207+
208+
/**
209+
* Encodes a phone-state update payload to send to the watch.
210+
*
211+
* @param stateType one of {@link #STATE_IDLE}, {@link #STATE_RINGING}, or
212+
* {@link #STATE_OFFHOOK}
213+
* @param phoneNumber incoming phone number, or empty string
214+
* @param contactName resolved contact name, or empty string
215+
* @return encoded bytes or {@code null} on error
216+
*/
217+
static byte[] encodeState(byte stateType, String phoneNumber, String contactName) {
218+
try {
219+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
220+
DataOutputStream dos = new DataOutputStream(baos);
221+
dos.writeByte(stateType);
222+
dos.writeUTF(phoneNumber != null ? phoneNumber : "");
223+
dos.writeUTF(contactName != null ? contactName : "");
224+
dos.flush();
225+
return baos.toByteArray();
226+
} catch (Exception e) {
227+
Log.e(TAG, "encodeState: failed", e);
228+
return null;
229+
}
230+
}
231+
232+
// -------------------------------------------------------------------------
233+
// PhoneStateListener inner class
234+
// -------------------------------------------------------------------------
235+
236+
private static final class WearPhoneStateListener extends PhoneStateListener {
237+
private final Context context;
238+
// WeakReference to avoid preventing WearableImpl GC
239+
private final WeakReference<WearableImpl> wearableRef;
240+
241+
WearPhoneStateListener(Context context, WearableImpl wearable) {
242+
this.context = context.getApplicationContext();
243+
this.wearableRef = new WeakReference<>(wearable);
244+
}
245+
246+
@Override
247+
public void onCallStateChanged(int state, String phoneNumber) {
248+
WearableImpl wearable = wearableRef.get();
249+
if (wearable == null) return;
250+
251+
byte type;
252+
switch (state) {
253+
case TelephonyManager.CALL_STATE_RINGING:
254+
type = STATE_RINGING;
255+
break;
256+
case TelephonyManager.CALL_STATE_OFFHOOK:
257+
type = STATE_OFFHOOK;
258+
break;
259+
default:
260+
type = STATE_IDLE;
261+
break;
262+
}
263+
264+
Log.d(TAG, "onCallStateChanged: type=" + type + ", number=" + phoneNumber);
265+
byte[] payload = encodeState(type, phoneNumber, "");
266+
if (payload == null) return;
267+
268+
for (String nodeId : wearable.getAllConnectedNodes()) {
269+
int result = wearable.sendMessage(context.getPackageName(), nodeId, PHONE_PATH, payload);
270+
if (result < 0) {
271+
Log.w(TAG, "sendMessage to " + nodeId + " failed (result=" + result + ")");
272+
}
273+
}
274+
}
275+
}
276+
}

0 commit comments

Comments
 (0)