Prd/components/dialer-patch/patches/0002-callrecorder-autostart-and-hold.patch
Lucy Doupalů be9f14ce34 Helpdesk - operator console + patched GrapheneOS Dialer for call handling
A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer
answers technician calls, records both call legs as separate channels, and a
Ruby backend transcribes them through Whisper and files an AI summary against
the caller.

Squashed to a single commit for sharing. No credentials are included; secrets
live outside the repo in /etc/helpdesk/env on the server or a gitignored
.claude/env.local locally. See .claude/env.local.example for the shape.

Start at README.md, then docs/architecture.md.
2026-07-27 18:50:32 +02:00

103 lines
5.7 KiB
Diff

diff --git a/java/com/android/incallui/call/CallRecorder.java b/java/com/android/incallui/call/CallRecorder.java
index 8509b5fe3..b85595b2d 100644
--- a/java/com/android/incallui/call/CallRecorder.java
+++ b/java/com/android/incallui/call/CallRecorder.java
@@ -36,7 +36,6 @@ import com.android.dialer.callrecord.ICallRecorderService;
import com.android.dialer.callrecord.impl.CallRecorderService;
import com.android.dialer.callrecord.impl.CallRecorderServiceV2;
import com.android.dialer.location.GeoUtil;
-import com.android.incallui.call.state.DialerCallState;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
@@ -75,6 +74,8 @@ public class CallRecorder implements CallList.Listener {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
CallRecorder.this.service = ICallRecorderService.Stub.asInterface(service);
+ // Helpdesk patch: if a call is already active when the recorder binds, auto-start recording.
+ handler.post(CallRecorder.this::maybeAutoStart);
}
@Override
@@ -116,11 +117,41 @@ public class CallRecorder implements CallList.Listener {
}
}
+ // Helpdesk patch: auto-record the active call. Called when a call goes active AND when the recorder
+ // service binds (whichever is later); isRecording() guards against a double start. Bypasses the
+ // manual record button (CallButtonPresenter) so no per-call consent dialog is shown.
+ private void maybeAutoStart() {
+ if (service == null || isRecording()) {
+ return;
+ }
+ DialerCall active = CallList.getInstance().getActiveCall();
+ if (active != null && startRecording(active.getNumber(), active.getCreationTimeMillis())) {
+ // Helpdesk patch: remember which call this recording belongs to (for the upload).
+ com.android.dialer.helpdesk.HelpdeskRecordingBridge.get().onRecordingStarted(active.getId());
+ }
+ }
+
public boolean startRecording(final String phoneNumber, final long creationTime) {
if (service == null) {
return false;
}
+ // Helpdesk patch: promote the recorder to a STARTED foreground service before recording. The
+ // service is otherwise bound-only, and on targetSdk<=34 a bound service promoted via
+ // startForeground() records its while-in-use reason into ServiceRecord.mAllowWiu_byBindings —
+ // a field the legacy capability calculation ignores — so the mic-FGS never receives
+ // PROCESS_CAPABILITY_FOREGROUND_MICROPHONE and (with RECORD_AUDIO pinned to MODE_FOREGROUND on
+ // GrapheneOS) AudioRecord is silently fed zeros whenever the InCall UI is not TOP (screen off /
+ // phone at ear). Starting the service here routes the reason into mAllowWiu_noBinding, which
+ // the legacy calc does read, so the mic capability is granted and persists for the FGS lifetime
+ // across the proximity screen-blank. AMS handles this synchronously before the binder call
+ // below, so the started-state exists by the time startForeground() runs in the service.
+ try {
+ context.startForegroundService(new Intent(context, CallRecorderServiceV2.class));
+ } catch (Exception e) {
+ Log.w(TAG, "startForegroundService for recorder failed; continuing with bound-only FGS", e);
+ }
+
try {
if (service.startRecording(phoneNumber, creationTime)) {
for (RecordingProgressListener l : progressListeners) {
@@ -170,6 +201,8 @@ public class CallRecorder implements CallList.Listener {
try {
final CallRecording recording = service.stopRecording();
if (recording != null) {
+ // Helpdesk patch: upload the finalized recording to the backend (feeds transcription).
+ com.android.dialer.helpdesk.HelpdeskRecordingBridge.get().onRecordingFinalized(recording.mediaId);
if (!TextUtils.isEmpty(recording.phoneNumber)) {
String msg = context.getResources().getString(R.string.call_recording_file_location, recording.fileName);
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show();
@@ -197,19 +230,18 @@ public class CallRecorder implements CallList.Listener {
if (!initialized && callList.getActiveCall() != null) {
// we'll come here if this is the first active call
initialize();
- } else {
- // we can come down this branch to resume a call that was on hold
- CallRecording active = getActiveRecording();
- if (active != null) {
- DialerCall call =
- callList.getCallWithStateAndNumber(DialerCallState.ONHOLD, active.phoneNumber);
- if (call != null) {
- // The call associated with the active recording has been placed
- // on hold, so stop the recording.
- finishRecording();
- }
- }
+ // Helpdesk patch: auto-record on answer (~500ms later, once the recorder service has bound).
+ handler.postDelayed(this::maybeAutoStart, UPDATE_INTERVAL);
}
+ // Helpdesk patch: do NOT stop recording when the call is placed on hold. Upstream stopped the
+ // recording here (and, in our build, that also finalized + uploaded it). But calling stopRecording()
+ // at the exact moment the VOICE_CALL audio path is torn down for the hold can leave the MP4 without
+ // its moov atom — a truncated, un-transcribable file — which then gets uploaded (the backend rejects
+ // it with "file is not a readable audio file"). Instead we keep the recorder running across holds and
+ // finalize exactly once, on disconnect (see onDisconnect), so a held-then-resumed call stays one
+ // continuous recording and the finalize happens after the audio path has settled. The recorder
+ // tolerates the silent hold gap (BaseCallRecorder treats a transient AudioRecord read error during a
+ // hold as a skip, not a stop).
}
@Override