android(call): volume key silences the ring like a real phone

The incoming ring is driven by our own Ringer (we aren't a telecom
ConnectionService yet), so the OS doesn't route volume keys to silence
it. Capture them two ways that together cover every state:

- Foreground/locked: IncomingCallActivity.dispatchKeyEvent consumes
  VOLUME_UP/DOWN/MUTE and silences.
- Unlocked/backgrounded heads-up (no activity): a MediaSession with a
  remote VolumeProvider so the framework delivers volume presses to us.

Silence stops audio + vibration but keeps the call ringing visually
(notification + full-screen stay) so answer/decline still work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
SravanandClaude Opus 4.8 committed 2026-10-29 22:10:17 +05:30
1 parent 7ec6c3f227
commit aa794102c3
2 files changed
+66

No files matched your search

@@ -9,6 +9,7 @@ import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.WindowManager;
import android.widget.LinearLayout;
@@ -214,6 +215,20 @@ public class IncomingCallActivity extends Activity {
finish();
}
// Volume keys silence the ring (like the system phone app) while the full-screen screen is in front. We
// consume the event so the stream volume / volume UI doesn't also react — the press only hushes the ring; the
// call keeps ringing (notification + screen stay) so the user can still answer/decline. The headless case
// (unlocked heads-up, no activity) is handled by Ringer's MediaSession VolumeProvider.
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
int k = event.getKeyCode();
if (k == KeyEvent.KEYCODE_VOLUME_UP || k == KeyEvent.KEYCODE_VOLUME_DOWN || k == KeyEvent.KEYCODE_VOLUME_MUTE) {
if (event.getAction() == KeyEvent.ACTION_DOWN) Ringer.silence();
return true; // consume down + up so the volume stream doesn't change
}
return super.dispatchKeyEvent(event);
}
@Override
protected void onDestroy() {
// Don't stop the ring here — it's call-scoped (Ringer), not screen-scoped. If the user just backgrounds the
@@ -4,6 +4,9 @@ import android.content.Context;
import android.media.AudioAttributes;
import android.media.Ringtone;
import android.media.RingtoneManager;
import android.media.VolumeProvider;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
import android.net.Uri;
import android.os.Build;
import android.os.Vibrator;
@@ -18,6 +21,10 @@ import android.util.Log;
class Ringer {
private static Ringtone ringtone;
private static Vibrator vibrator;
// A dummy MediaSession whose only job is to intercept the hardware volume keys so the FIRST press silences the
// ring (see startVolumeKeyCapture). We aren't a telecom ConnectionService yet (Increment 2), so the OS won't
// route volume/power to us on its own.
private static MediaSession mediaSession;
static synchronized void start(Context ctx) {
if (ringtone != null) return; // already ringing
@@ -51,6 +58,44 @@ class Ringer {
vibrator = v;
}
} catch (Exception ignored) {}
startVolumeKeyCapture(app);
}
// Make the hardware volume keys silence the ring, exactly like the system phone app. Because we are not a
// telecom ConnectionService (that's Increment 2), the OS won't deliver volume/power presses to us while
// ringing. A MediaSession flagged as actively "playing" with a *remote* VolumeProvider makes the framework
// route volume-key presses to our VolumeProvider instead of changing a stream — even when no activity of ours
// is in the foreground (the unlocked heads-up case). The foreground/locked full-screen activity also forwards
// its own key events here via dispatchKeyEvent, so every state is covered.
private static void startVolumeKeyCapture(Context app) {
try {
MediaSession s = new MediaSession(app, "BzCallRinger");
s.setPlaybackState(new PlaybackState.Builder()
.setState(PlaybackState.STATE_PLAYING, 0, 1f)
.setActions(0)
.build());
VolumeProvider vp = new VolumeProvider(VolumeProvider.VOLUME_CONTROL_RELATIVE, 100, 100) {
@Override public void onAdjustVolume(int direction) { silence(); }
@Override public void onSetVolumeTo(int volume) { silence(); }
};
s.setPlaybackToRemote(vp);
s.setActive(true);
mediaSession = s;
Log.i("bzcall", "volume-key capture armed");
} catch (Exception e) { Log.w("bzcall", "volume-key capture failed: " + e.getMessage()); }
}
// Stop the NOISE (audio + vibration) but leave the call ringing visually — the notification and the
// full-screen screen stay up so the user can still answer/decline. This is what a volume/power press does on a
// real phone: it hushes the ringtone, it does not reject the call. Idempotent.
static synchronized void silence() {
boolean wasRinging = ringtone != null || vibrator != null || mediaSession != null;
try { if (ringtone != null) ringtone.stop(); } catch (Exception ignored) {}
ringtone = null;
try { if (vibrator != null) vibrator.cancel(); } catch (Exception ignored) {}
vibrator = null;
releaseSession();
if (wasRinging) Log.i("bzcall", "ringer silenced (volume/power)");
}
static synchronized void stop() {
@@ -58,5 +103,11 @@ class Ringer {
ringtone = null;
try { if (vibrator != null) vibrator.cancel(); } catch (Exception ignored) {}
vibrator = null;
releaseSession();
}
private static void releaseSession() {
try { if (mediaSession != null) { mediaSession.setActive(false); mediaSession.release(); } } catch (Exception ignored) {}
mediaSession = null;
}
}