Android calls: professional incoming UI + killed-state ring reliability

Addresses on-device feedback from Increment 1:
- #2 Professional incoming screen: IncomingCallActivity redesigned — brand-
  blue backdrop, circular avatar with the caller's initial, name + call
  type, and two round icon buttons (red hang-up = Decline, green phone =
  Accept) instead of the full-screen colored buttons. New vector drawables
  ic_call_answer / ic_call_decline (also used on the notification actions).
- #1 Killed-state ring: the missed-call notification arriving while killed
  proves FCM reaches the app, so the ring (high-priority DATA) is being
  held by OEM battery management (OnePlus/Oppo Doze) until the call is over.
  Added REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + a requestCallReliability()
  plugin method that prompts the "allow background activity" dialog once;
  the web calls it on Android call setup. (OEM auto-launch is a manual
  toggle with no API — guide the user.)

#3 (speaker/Bluetooth) and #4 (background audio) ride Increment 2 (native
LiveKit Android media + foreground service) — kept separate so the LiveKit
dependency can't block these UI/reliability fixes (the iOS coupling lesson).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
SravanandClaude Opus 4.8 committed 2026-09-27 15:15:18 +05:30
1 parent b60ce6cda3
commit 5cb3d0c122
7 files changed
+140 -37

No files matched your search

@@ -6,6 +6,9 @@
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Ask to be exempt from battery optimization so high-priority FCM call-wakes reach the app when it's
been swiped away / Dozing (OnePlus/Oppo hold data messages otherwise → the ring arrives too late). -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application>
<!-- Our FirebaseMessagingService REPLACES the @capacitor/push-notifications one: it handles call-wake
@@ -95,8 +95,8 @@ class CallStore {
.setAutoCancel(false)
.setContentIntent(full)
.setFullScreenIntent(full, true)
.addAction(0, "Decline", decline)
.addAction(0, "Accept", accept);
.addAction(R.drawable.ic_call_decline, "Decline", decline)
.addAction(R.drawable.ic_call_answer, "Accept", accept);
try {
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, b.build());
@@ -13,7 +13,6 @@ import android.os.Bundle;
import android.view.Gravity;
import android.view.View;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;
@@ -77,62 +76,113 @@ public class IncomingCallActivity extends Activity {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
// A clean, phone-style incoming call screen: brand-blue backdrop, a circular avatar with the caller's
// initial, the name + call type, and two round icon buttons (red hang-up = decline, green phone = accept).
private View buildUi() {
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setGravity(Gravity.CENTER);
root.setGravity(Gravity.CENTER_HORIZONTAL);
root.setBackgroundColor(Color.parseColor("#16294F")); // brand blue
int pad = dp(24);
root.setPadding(pad, pad, pad, pad);
int side = dp(28);
root.setPadding(side, dp(64), side, dp(56));
boolean video = "true".equals(callData.getString("hasVideo", "false"));
String name = callData.getString("callerName", "Incoming call");
if (name == null || name.isEmpty()) name = "Incoming call";
TextView label = new TextView(this);
label.setText(video ? "Incoming video call" : "Incoming call");
label.setTextColor(Color.parseColor("#BBD0FF"));
label.setTextSize(16);
label.setGravity(Gravity.CENTER);
// Top spacer pushes the avatar/name to the upper third.
root.addView(new View(this), new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
View avatar = avatarView(name);
LinearLayout.LayoutParams avLp = new LinearLayout.LayoutParams(dp(96), dp(96));
avLp.gravity = Gravity.CENTER_HORIZONTAL;
avLp.bottomMargin = dp(22);
root.addView(avatar, avLp);
TextView who = new TextView(this);
who.setText(name);
who.setTextColor(Color.WHITE);
who.setTextSize(30);
who.setTextSize(27);
who.setGravity(Gravity.CENTER);
LinearLayout.LayoutParams whoLp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
whoLp.topMargin = dp(12);
whoLp.bottomMargin = dp(48);
who.setTypeface(who.getTypeface(), android.graphics.Typeface.BOLD);
root.addView(who, wrap(Gravity.CENTER_HORIZONTAL, dp(6)));
TextView label = new TextView(this);
label.setText(video ? "Incoming video call" : "Incoming voice call");
label.setTextColor(Color.parseColor("#AEC2EA"));
label.setTextSize(15);
label.setGravity(Gravity.CENTER);
root.addView(label, wrap(Gravity.CENTER_HORIZONTAL, 0));
// Larger spacer drops the buttons to the bottom.
root.addView(new View(this), new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1.5f));
LinearLayout buttons = new LinearLayout(this);
buttons.setOrientation(LinearLayout.HORIZONTAL);
buttons.setGravity(Gravity.CENTER);
buttons.addView(callButtonColumn("Decline", "#DC2626", R.drawable.ic_call_decline, v -> doDecline()),
new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
buttons.addView(callButtonColumn("Accept", "#16A34A", R.drawable.ic_call_answer, v -> doAccept()),
new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
root.addView(buttons, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
Button decline = actionButton("Decline", "#DC2626");
decline.setOnClickListener(v -> doDecline());
Button accept = actionButton("Accept", "#16A34A");
accept.setOnClickListener(v -> doAccept());
LinearLayout.LayoutParams bl = new LinearLayout.LayoutParams(dp(120), dp(56));
bl.rightMargin = dp(20);
LinearLayout.LayoutParams br = new LinearLayout.LayoutParams(dp(120), dp(56));
buttons.addView(decline, bl);
buttons.addView(accept, br);
root.addView(label);
root.addView(who, whoLp);
root.addView(buttons);
return root;
}
private Button actionButton(String text, String colorHex) {
Button b = new Button(this);
b.setText(text);
b.setTextColor(Color.WHITE);
b.setAllCaps(false);
b.setBackgroundColor(Color.parseColor(colorHex));
return b;
private LinearLayout.LayoutParams wrap(int gravity, int bottomMargin) {
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
lp.gravity = gravity;
lp.bottomMargin = bottomMargin;
return lp;
}
// Circular avatar with the caller's first initial.
private View avatarView(String name) {
android.widget.FrameLayout f = new android.widget.FrameLayout(this);
android.graphics.drawable.GradientDrawable bg = new android.graphics.drawable.GradientDrawable();
bg.setShape(android.graphics.drawable.GradientDrawable.OVAL);
bg.setColor(Color.parseColor("#2F4E8F"));
f.setBackground(bg);
TextView initial = new TextView(this);
String ch = (name != null && !name.trim().isEmpty()) ? name.trim().substring(0, 1).toUpperCase() : "?";
initial.setText(ch);
initial.setTextColor(Color.WHITE);
initial.setTextSize(40);
initial.setGravity(Gravity.CENTER);
f.addView(initial, new android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT, android.widget.FrameLayout.LayoutParams.MATCH_PARENT));
return f;
}
// A round colored icon button with a caption underneath.
private View callButtonColumn(String caption, String colorHex, int iconRes, View.OnClickListener onClick) {
LinearLayout col = new LinearLayout(this);
col.setOrientation(LinearLayout.VERTICAL);
col.setGravity(Gravity.CENTER_HORIZONTAL);
android.widget.ImageButton btn = new android.widget.ImageButton(this);
android.graphics.drawable.GradientDrawable circle = new android.graphics.drawable.GradientDrawable();
circle.setShape(android.graphics.drawable.GradientDrawable.OVAL);
circle.setColor(Color.parseColor(colorHex));
btn.setBackground(circle);
btn.setImageResource(iconRes);
btn.setColorFilter(Color.WHITE);
btn.setScaleType(android.widget.ImageView.ScaleType.CENTER);
btn.setOnClickListener(onClick);
col.addView(btn, new LinearLayout.LayoutParams(dp(72), dp(72)));
TextView t = new TextView(this);
t.setText(caption);
t.setTextColor(Color.parseColor("#D5E1F5"));
t.setTextSize(14);
t.setGravity(Gravity.CENTER);
LinearLayout.LayoutParams tLp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
tLp.topMargin = dp(10);
tLp.gravity = Gravity.CENTER_HORIZONTAL;
col.addView(t, tLp);
return col;
}
private void startRinging() {
@@ -113,6 +113,35 @@ public class NativeCallPlugin extends Plugin {
// Increment 1: mute is handled by the WebView (it owns the media). No-op so the shared bridge doesn't reject.
@PluginMethod public void setMuted(PluginCall call) { call.resolve(); }
// Ask the OS to exempt the app from battery optimization. Without this, aggressive OEMs (OnePlus/Oppo/Xiaomi)
// hold high-priority FCM call-wake messages while the app is swiped-away/Dozing, so an incoming call doesn't
// ring until it's already over. Shows the system "allow background activity" dialog once; harmless if already
// exempt. (OEM "auto-launch"/"allow background" toggles are separate and can only be enabled by the user.)
@PluginMethod
public void requestCallReliability(PluginCall call) {
com.getcapacitor.JSObject ret = new com.getcapacitor.JSObject();
try {
android.content.Context ctx = getContext();
android.os.PowerManager pm = (android.os.PowerManager) ctx.getSystemService(android.content.Context.POWER_SERVICE);
String pkg = ctx.getPackageName();
boolean ignoring = pm != null && pm.isIgnoringBatteryOptimizations(pkg);
ret.put("ignoringBatteryOptimizations", ignoring);
if (!ignoring && Boolean.TRUE.equals(call.getBoolean("prompt", true))) {
android.app.Activity act = getActivity();
android.content.Context launcher = act != null ? act : ctx;
android.content.Intent i = new android.content.Intent(
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
android.net.Uri.parse("package:" + pkg));
i.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK);
launcher.startActivity(i);
ret.put("prompted", true);
}
} catch (Exception e) {
ret.put("error", e.getMessage() == null ? "failed" : e.getMessage());
}
call.resolve(ret);
}
// --- Increment 2+ methods (video / screen-share / hole-punch / #12 reconnect / #5 transcript). Safe no-ops
// for now so the shared web bridge never rejects on Android; real bodies land with the native-media build. ---
@PluginMethod public void reconnectRoom(PluginCall call) { call.resolve(); }
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="26dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="26dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z" />
</vector>