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:
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.USE_FULL_SCREEN_INTENT" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<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>
|
<application>
|
||||||
<!-- Our FirebaseMessagingService REPLACES the @capacitor/push-notifications one: it handles call-wake
|
<!-- Our FirebaseMessagingService REPLACES the @capacitor/push-notifications one: it handles call-wake
|
||||||
|
|||||||
+2
-2
@@ -95,8 +95,8 @@ class CallStore {
|
|||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
.setContentIntent(full)
|
.setContentIntent(full)
|
||||||
.setFullScreenIntent(full, true)
|
.setFullScreenIntent(full, true)
|
||||||
.addAction(0, "Decline", decline)
|
.addAction(R.drawable.ic_call_decline, "Decline", decline)
|
||||||
.addAction(0, "Accept", accept);
|
.addAction(R.drawable.ic_call_answer, "Accept", accept);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, b.build());
|
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, b.build());
|
||||||
|
|||||||
+85
-35
@@ -13,7 +13,6 @@ import android.os.Bundle;
|
|||||||
import android.view.Gravity;
|
import android.view.Gravity;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
import android.widget.Button;
|
|
||||||
import android.widget.LinearLayout;
|
import android.widget.LinearLayout;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
@@ -77,62 +76,113 @@ public class IncomingCallActivity extends Activity {
|
|||||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
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() {
|
private View buildUi() {
|
||||||
LinearLayout root = new LinearLayout(this);
|
LinearLayout root = new LinearLayout(this);
|
||||||
root.setOrientation(LinearLayout.VERTICAL);
|
root.setOrientation(LinearLayout.VERTICAL);
|
||||||
root.setGravity(Gravity.CENTER);
|
root.setGravity(Gravity.CENTER_HORIZONTAL);
|
||||||
root.setBackgroundColor(Color.parseColor("#16294F")); // brand blue
|
root.setBackgroundColor(Color.parseColor("#16294F")); // brand blue
|
||||||
int pad = dp(24);
|
int side = dp(28);
|
||||||
root.setPadding(pad, pad, pad, pad);
|
root.setPadding(side, dp(64), side, dp(56));
|
||||||
|
|
||||||
boolean video = "true".equals(callData.getString("hasVideo", "false"));
|
boolean video = "true".equals(callData.getString("hasVideo", "false"));
|
||||||
String name = callData.getString("callerName", "Incoming call");
|
String name = callData.getString("callerName", "Incoming call");
|
||||||
if (name == null || name.isEmpty()) name = "Incoming call";
|
if (name == null || name.isEmpty()) name = "Incoming call";
|
||||||
|
|
||||||
TextView label = new TextView(this);
|
// Top spacer pushes the avatar/name to the upper third.
|
||||||
label.setText(video ? "Incoming video call" : "Incoming call");
|
root.addView(new View(this), new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||||
label.setTextColor(Color.parseColor("#BBD0FF"));
|
|
||||||
label.setTextSize(16);
|
View avatar = avatarView(name);
|
||||||
label.setGravity(Gravity.CENTER);
|
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);
|
TextView who = new TextView(this);
|
||||||
who.setText(name);
|
who.setText(name);
|
||||||
who.setTextColor(Color.WHITE);
|
who.setTextColor(Color.WHITE);
|
||||||
who.setTextSize(30);
|
who.setTextSize(27);
|
||||||
who.setGravity(Gravity.CENTER);
|
who.setGravity(Gravity.CENTER);
|
||||||
LinearLayout.LayoutParams whoLp = new LinearLayout.LayoutParams(
|
who.setTypeface(who.getTypeface(), android.graphics.Typeface.BOLD);
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
|
root.addView(who, wrap(Gravity.CENTER_HORIZONTAL, dp(6)));
|
||||||
whoLp.topMargin = dp(12);
|
|
||||||
whoLp.bottomMargin = dp(48);
|
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);
|
LinearLayout buttons = new LinearLayout(this);
|
||||||
buttons.setOrientation(LinearLayout.HORIZONTAL);
|
buttons.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
buttons.setGravity(Gravity.CENTER);
|
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;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Button actionButton(String text, String colorHex) {
|
private LinearLayout.LayoutParams wrap(int gravity, int bottomMargin) {
|
||||||
Button b = new Button(this);
|
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||||
b.setText(text);
|
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
|
||||||
b.setTextColor(Color.WHITE);
|
lp.gravity = gravity;
|
||||||
b.setAllCaps(false);
|
lp.bottomMargin = bottomMargin;
|
||||||
b.setBackgroundColor(Color.parseColor(colorHex));
|
return lp;
|
||||||
return b;
|
}
|
||||||
|
|
||||||
|
// 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() {
|
private void startRinging() {
|
||||||
|
|||||||
+29
@@ -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.
|
// 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(); }
|
@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
|
// --- 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. ---
|
// 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(); }
|
@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>
|
||||||
@@ -4350,6 +4350,9 @@ async function setupNativeCall(){
|
|||||||
// token is already registered by setupNativePush — the server call-wake targets it, so no token step here.
|
// token is already registered by setupNativePush — the server call-wake targets it, so no token step here.
|
||||||
if(!cfg || !cfg.androidCall){ console.log('[nativecall] Android native calling disabled by server — using WebView calls'); return; }
|
if(!cfg || !cfg.androidCall){ console.log('[nativecall] Android native calling disabled by server — using WebView calls'); return; }
|
||||||
_callkitReady=true; _callWebViewMedia=true;
|
_callkitReady=true; _callWebViewMedia=true;
|
||||||
|
// Ask for a battery-optimization exemption so a killed/Dozing phone still gets the high-priority FCM
|
||||||
|
// call-wake in time to ring (OnePlus/Oppo hold it otherwise). Prompt only the first time; then just check.
|
||||||
|
try{ if(NC.requestCallReliability){ const asked=localStorage.getItem('bzcCallRelAsked'); const rel=await NC.requestCallReliability({ prompt: !asked }); localStorage.setItem('bzcCallRelAsked','1'); if(rel && rel.ignoringBatteryOptimizations===false) console.log('[nativecall] app is NOT exempt from battery optimization — killed-state ring may be delayed until this is allowed'); } }catch(_){}
|
||||||
} else {
|
} else {
|
||||||
// iOS CallKit: native media (the plugin owns this identity's one LiveKit connection). Flipped on once native
|
// iOS CallKit: native media (the plugin owns this identity's one LiveKit connection). Flipped on once native
|
||||||
// media carries the audio.
|
// media carries the audio.
|
||||||
|
|||||||
Reference in new issue
Block a user