dm calls mvp: phase 2.5.3: lockscreen CallStyle with FSI, Answer/Decline, ringtone channel, permission prompt
This commit is contained in:
parent
aa958b6e76
commit
53a8bda18f
19 changed files with 841 additions and 30 deletions
|
|
@ -11,6 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
implementation project(':capacitor-browser')
|
implementation project(':capacitor-browser')
|
||||||
|
implementation project(':capacitor-preferences')
|
||||||
implementation project(':capacitor-push-notifications')
|
implementation project(':capacitor-push-notifications')
|
||||||
implementation project(':capacitor-toast')
|
implementation project(':capacitor-toast')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,24 @@
|
||||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".CallCancelReceiver"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- DM voice calls: mic + audio routing. Capacitor auto-requests at getUserMedia time. -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<!-- Required to unblock NotificationCompat.CallStyle on API 31+: NMS's
|
||||||
|
checkDisqualifyingFeatures rejects CallStyle notifications without
|
||||||
|
FSI/FGS/UIJ, throwing IllegalArgumentException on its own handler
|
||||||
|
thread (silent to the app). Declaring the permission flips
|
||||||
|
FLAG_FSI_REQUESTED_BUT_DENIED so the gate passes, even though we
|
||||||
|
never call setFullScreenIntent(). See ADR 2.5-heads-up. -->
|
||||||
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismisses the incoming-call notification when its RTC lifetime expires.
|
||||||
|
*
|
||||||
|
* Scheduled by {@link VojoFirebaseMessagingService} via AlarmManager at
|
||||||
|
* sender_ts + lifetime. The extras carry tag/id so we can target the exact
|
||||||
|
* notification even if multiple ring pushes overlap (rare but possible across
|
||||||
|
* rapid re-dials).
|
||||||
|
*
|
||||||
|
* Also invoked directly (same intent shape) when the app receives a decline /
|
||||||
|
* other-device-answer via the live Matrix sync and wants the system notification
|
||||||
|
* cleared — see Capacitor removeDeliveredNotifications in the JS layer.
|
||||||
|
*/
|
||||||
|
public class CallCancelReceiver extends BroadcastReceiver {
|
||||||
|
|
||||||
|
public static final String ACTION_CANCEL_CALL = "chat.vojo.app.CANCEL_CALL";
|
||||||
|
public static final String EXTRA_NOTIF_TAG = "notif_tag";
|
||||||
|
public static final String EXTRA_NOTIF_ID = "notif_id";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent) {
|
||||||
|
if (intent == null || !ACTION_CANCEL_CALL.equals(intent.getAction())) return;
|
||||||
|
String tag = intent.getStringExtra(EXTRA_NOTIF_TAG);
|
||||||
|
int id = intent.getIntExtra(EXTRA_NOTIF_ID, -1);
|
||||||
|
if (tag == null || id == -1) return;
|
||||||
|
NotificationManager nm =
|
||||||
|
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
|
if (nm == null) return;
|
||||||
|
nm.cancel(tag, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.provider.Settings;
|
||||||
|
|
||||||
|
import com.getcapacitor.JSObject;
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginCall;
|
||||||
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges Android 14+ (API 34) full-screen-intent opt-in into JS.
|
||||||
|
*
|
||||||
|
* On API 34 `USE_FULL_SCREEN_INTENT` was reclassified from "normal" to
|
||||||
|
* "special appop" — declaring it in the manifest is no longer enough to
|
||||||
|
* actually display a full-screen notification over the lockscreen. The user
|
||||||
|
* must opt in via Settings → Apps → Vojo → Full-screen notifications. There's
|
||||||
|
* no runtime grant API, only a deep-link.
|
||||||
|
*
|
||||||
|
* Without the opt-in, `setFullScreenIntent(launchPI, true)` still satisfies
|
||||||
|
* the AOSP NotificationManagerService gate (so CallStyle doesn't get silently
|
||||||
|
* dropped), but the notification renders as a regular heads-up and the screen
|
||||||
|
* doesn't wake over the lockscreen — which was the "why is this just a banner
|
||||||
|
* on the lockscreen?" symptom we saw on the Samsung OneUI test device.
|
||||||
|
*
|
||||||
|
* See docs/plans/dm_calls.md ADR 2.5-fsi for the full history.
|
||||||
|
*/
|
||||||
|
@CapacitorPlugin(name = "FullScreenIntent")
|
||||||
|
public class FullScreenIntentPlugin extends Plugin {
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void canUseFullScreenIntent(PluginCall call) {
|
||||||
|
JSObject ret = new JSObject();
|
||||||
|
ret.put("value", canUseFullScreenIntentInternal());
|
||||||
|
call.resolve(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void openSettings(PluginCall call) {
|
||||||
|
Context ctx = getContext();
|
||||||
|
Intent intent;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
// API 34+ has a dedicated Settings screen for the full-screen notification opt-in.
|
||||||
|
intent = new Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT);
|
||||||
|
intent.setData(Uri.parse("package:" + ctx.getPackageName()));
|
||||||
|
} else {
|
||||||
|
// Fallback for API ≤33: the per-app notification Settings page is the closest
|
||||||
|
// equivalent and also covers channel-level toggles (mute, DND bypass, etc).
|
||||||
|
intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS);
|
||||||
|
intent.putExtra(Settings.EXTRA_APP_PACKAGE, ctx.getPackageName());
|
||||||
|
}
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
try {
|
||||||
|
ctx.startActivity(intent);
|
||||||
|
call.resolve();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
call.reject("Failed to open FSI settings: " + t.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean canUseFullScreenIntentInternal() {
|
||||||
|
// On API ≤33 `USE_FULL_SCREEN_INTENT` is a normal permission — if it's
|
||||||
|
// declared in the manifest, the app already has it. Skip the runtime check.
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true;
|
||||||
|
NotificationManager nm = (NotificationManager)
|
||||||
|
getContext().getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
|
if (nm == null) return false;
|
||||||
|
return nm.canUseFullScreenIntent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,10 @@ public class MainActivity extends BridgeActivity {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
// Custom plugins must be registered before super.onCreate so BridgeActivity
|
||||||
|
// can wire them into the WebView bridge on load. Registering after
|
||||||
|
// super.onCreate would make the plugin invisible to JS until the next relaunch.
|
||||||
|
registerPlugin(FullScreenIntentPlugin.class);
|
||||||
EdgeToEdge.enable(this);
|
EdgeToEdge.enable(this);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
package chat.vojo.app;
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.app.AlarmManager;
|
||||||
|
import android.app.NotificationChannel;
|
||||||
import android.app.NotificationManager;
|
import android.app.NotificationManager;
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.media.AudioAttributes;
|
||||||
|
import android.media.RingtoneManager;
|
||||||
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.core.app.NotificationCompat;
|
import androidx.core.app.NotificationCompat;
|
||||||
|
import androidx.core.app.Person;
|
||||||
|
|
||||||
import com.capacitorjs.plugins.pushnotifications.MessagingService;
|
import com.capacitorjs.plugins.pushnotifications.MessagingService;
|
||||||
import com.google.firebase.messaging.RemoteMessage;
|
import com.google.firebase.messaging.RemoteMessage;
|
||||||
|
|
@ -19,27 +26,68 @@ import java.util.Map;
|
||||||
* base MessagingService just forwards to JS via `pushNotificationReceived`,
|
* base MessagingService just forwards to JS via `pushNotificationReceived`,
|
||||||
* but JS listeners detach when the WebView is paused/backgrounded.
|
* but JS listeners detach when the WebView is paused/backgrounded.
|
||||||
*
|
*
|
||||||
* This service builds a system notification whenever the activity is NOT in
|
* Message branch: builds a system notification when the activity is NOT in
|
||||||
* the foreground — covering both the "backgrounded" and "killed" cases.
|
* the foreground — covering both "backgrounded" and "killed" cases.
|
||||||
* When the activity IS visible, in-app MessageNotifications handles display.
|
|
||||||
*
|
*
|
||||||
* Each message gets its own notification (unique id per event_id). Android
|
* Call branch: for `org.matrix.msc4075.rtc.notification` + `notification_type=ring`
|
||||||
* auto-groups them under the "vojo_messages" group when there are 4+.
|
* we always show a CallStyle incoming-call notification (independent of
|
||||||
|
* foreground state) with Answer/Decline actions + full-screen intent that
|
||||||
|
* wakes the device and launches MainActivity over the lockscreen — the
|
||||||
|
* WhatsApp/Telegram incoming-call UX. The FSI is also what satisfies AOSP
|
||||||
|
* NotificationManagerService.checkDisqualifyingFeatures on API 31+; without
|
||||||
|
* it CallStyle throws IAE and the notification is silently dropped. See
|
||||||
|
* docs/plans/dm_calls.md ADR 2.5-fsi.
|
||||||
*/
|
*/
|
||||||
public class VojoFirebaseMessagingService extends MessagingService {
|
public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
|
|
||||||
private static final String CHANNEL_ID = "vojo_messages";
|
private static final String CHANNEL_ID = "vojo_messages";
|
||||||
private static final String GROUP_KEY = "vojo_messages";
|
private static final String GROUP_KEY = "vojo_messages";
|
||||||
|
// NotificationChannel settings (vibration pattern, sound, importance) are
|
||||||
|
// immutable after creation on API 26+. Bump this ID whenever the channel
|
||||||
|
// config changes so upgrades actually pick up the new settings instead of
|
||||||
|
// silently using the stale channel the user already has. v2 = 20-pulse
|
||||||
|
// ring vibration + ringtone sound (previously ~2 pulses, silent tail).
|
||||||
|
private static final String CALL_CHANNEL_ID = "vojo_calls_v2";
|
||||||
|
private static final String LEGACY_CALL_CHANNEL_ID = "vojo_calls";
|
||||||
// Reserved id for the group summary. Chosen as MIN_VALUE so it can't collide
|
// Reserved id for the group summary. Chosen as MIN_VALUE so it can't collide
|
||||||
// with String.hashCode() of any event/room key (which notoriously returns 0
|
// with String.hashCode() of any event/room key (which notoriously returns 0
|
||||||
// for the empty string and a handful of other inputs).
|
// for the empty string and a handful of other inputs).
|
||||||
private static final int SUMMARY_NOTIFICATION_ID = Integer.MIN_VALUE;
|
private static final int SUMMARY_NOTIFICATION_ID = Integer.MIN_VALUE;
|
||||||
|
|
||||||
|
private static final String RTC_NOTIFICATION_TYPE = "org.matrix.msc4075.rtc.notification";
|
||||||
|
private static final long RTC_DEFAULT_LIFETIME_MS = 30_000L;
|
||||||
|
private static final long RTC_LIFETIME_GRACE_MS = 2_000L;
|
||||||
|
|
||||||
|
private static final String TAG = "VojoFCM";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onMessageReceived(RemoteMessage remoteMessage) {
|
public void onMessageReceived(RemoteMessage remoteMessage) {
|
||||||
super.onMessageReceived(remoteMessage);
|
super.onMessageReceived(remoteMessage);
|
||||||
if (!MainActivity.isInForeground) {
|
Map<String, String> data = remoteMessage.getData();
|
||||||
showSystemNotification(remoteMessage);
|
Log.d(TAG, "recv: type=" + data.get("type")
|
||||||
|
+ " cn_type=" + data.get("content_notification_type")
|
||||||
|
+ " room=" + data.get("room_id")
|
||||||
|
+ " event=" + data.get("event_id")
|
||||||
|
+ " fg=" + MainActivity.isInForeground);
|
||||||
|
try {
|
||||||
|
if (RTC_NOTIFICATION_TYPE.equals(data.get("type"))
|
||||||
|
&& "ring".equals(data.get("content_notification_type"))) {
|
||||||
|
Log.d(TAG, "route: call-branch");
|
||||||
|
showIncomingCallNotification(remoteMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!MainActivity.isInForeground) {
|
||||||
|
Log.d(TAG, "route: message-branch (background)");
|
||||||
|
showSystemNotification(remoteMessage);
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "route: skip (foreground, non-call)");
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
// Don't let any notification-construction bug crash the FCM service — if we
|
||||||
|
// do, Android restarts the process for the next push (PID churn visible in
|
||||||
|
// logcat) and eventually shows the "app keeps stopping" dialog to the user.
|
||||||
|
// Better to swallow + log and lose one notification than kill the process.
|
||||||
|
Log.e(TAG, "onMessageReceived: uncaught exception in notification flow", t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,13 +136,24 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE);
|
.setCategory(NotificationCompat.CATEGORY_MESSAGE);
|
||||||
|
|
||||||
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
if (nm == null) return;
|
if (nm == null) {
|
||||||
|
Log.w(TAG, "msg: NotificationManager is null, abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureMessageChannel(nm);
|
||||||
|
|
||||||
// Unique notification id per event — each message shows separately in the shade.
|
// Unique notification id per event — each message shows separately in the shade.
|
||||||
// Guard against the (rare) hashCode collision with the reserved summary id.
|
// Guard against the (rare) hashCode collision with the reserved summary id.
|
||||||
int notifId = uniqueKey.hashCode();
|
int notifId = uniqueKey.hashCode();
|
||||||
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
|
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
|
||||||
nm.notify(notifId, builder.build());
|
Log.d(TAG, "msg: posting notif id=" + notifId + " channel=" + CHANNEL_ID
|
||||||
|
+ " notifsEnabled=" + nm.areNotificationsEnabled());
|
||||||
|
try {
|
||||||
|
nm.notify(notifId, builder.build());
|
||||||
|
} catch (SecurityException e) {
|
||||||
|
Log.e(TAG, "msg: nm.notify threw SecurityException", e);
|
||||||
|
}
|
||||||
|
|
||||||
// Summary notification for the group (Android shows this when 4+ notifications stack)
|
// Summary notification for the group (Android shows this when 4+ notifications stack)
|
||||||
NotificationCompat.Builder summary = new NotificationCompat.Builder(this, CHANNEL_ID)
|
NotificationCompat.Builder summary = new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
|
@ -107,6 +166,228 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
nm.notify(SUMMARY_NOTIFICATION_ID, summary.build());
|
nm.notify(SUMMARY_NOTIFICATION_ID, summary.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void showIncomingCallNotification(RemoteMessage message) {
|
||||||
|
Map<String, String> data = message.getData();
|
||||||
|
String roomId = data.get("room_id");
|
||||||
|
String notifEventId = data.get("event_id");
|
||||||
|
if (roomId == null || notifEventId == null) {
|
||||||
|
Log.w(TAG, "call: missing roomId/eventId, abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
|
if (nm == null) {
|
||||||
|
Log.w(TAG, "call: NotificationManager is null, abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureCallChannel(nm);
|
||||||
|
|
||||||
|
String callerName = firstNonEmpty(
|
||||||
|
data.get("sender_display_name"),
|
||||||
|
data.get("room_name"),
|
||||||
|
data.get("sender"),
|
||||||
|
"Vojo"
|
||||||
|
);
|
||||||
|
String messageId = message.getMessageId();
|
||||||
|
String tag = "call_" + roomId;
|
||||||
|
int notifId = tag.hashCode();
|
||||||
|
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
|
||||||
|
|
||||||
|
// Different request codes per action — FLAG_UPDATE_CURRENT would otherwise
|
||||||
|
// rewrite the previously-stored extras across answer/decline/launch intents.
|
||||||
|
int answerReq = ("ans_" + notifEventId).hashCode();
|
||||||
|
int declineReq = ("dec_" + notifEventId).hashCode();
|
||||||
|
int launchReq = ("open_" + notifEventId).hashCode();
|
||||||
|
|
||||||
|
PendingIntent answerPI = buildActionPI(
|
||||||
|
answerReq, "answer", roomId, notifEventId, messageId
|
||||||
|
);
|
||||||
|
PendingIntent declinePI = buildActionPI(
|
||||||
|
declineReq, "decline", roomId, notifEventId, messageId
|
||||||
|
);
|
||||||
|
PendingIntent launchPI = buildActionPI(
|
||||||
|
launchReq, null, roomId, notifEventId, messageId
|
||||||
|
);
|
||||||
|
|
||||||
|
Person caller = new Person.Builder().setName(callerName).build();
|
||||||
|
|
||||||
|
Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
|
||||||
|
|
||||||
|
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CALL_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setContentIntent(launchPI)
|
||||||
|
// Full-screen wakeup over lockscreen — the "real" incoming-call UX
|
||||||
|
// (WhatsApp/Telegram-style). Also the only reliable way to satisfy
|
||||||
|
// AOSP's checkDisqualifyingFeatures gate for CallStyle on API 31+;
|
||||||
|
// Samsung OneUI specifically rejects CallStyle with `fgs=false` +
|
||||||
|
// `FullScreenIntent=null` even when USE_FULL_SCREEN_INTENT is just
|
||||||
|
// declared. Requires the permission in the manifest.
|
||||||
|
.setFullScreenIntent(launchPI, true)
|
||||||
|
.setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, declinePI, answerPI));
|
||||||
|
|
||||||
|
// Builder-level sound/vibration are ignored on API 26+ once the
|
||||||
|
// channel has its own settings — we configure both on the channel in
|
||||||
|
// ensureCallChannel() and skip the redundant builder calls here.
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
if (ringtoneUri != null) builder.setSound(ringtoneUri);
|
||||||
|
builder.setVibrate(buildRingVibrationPattern());
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "call: posting notif tag=" + tag + " id=" + notifId
|
||||||
|
+ " channel=" + CALL_CHANNEL_ID + " notifsEnabled=" + nm.areNotificationsEnabled());
|
||||||
|
try {
|
||||||
|
nm.notify(tag, notifId, builder.build());
|
||||||
|
Log.d(TAG, "call: nm.notify returned OK");
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.e(TAG, "call: nm.notify threw", t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
scheduleCallNotificationExpiry(data, tag, notifId);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.e(TAG, "call: scheduleCallNotificationExpiry threw", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PendingIntent buildActionPI(
|
||||||
|
int requestCode,
|
||||||
|
String callAction,
|
||||||
|
String roomId,
|
||||||
|
String notifEventId,
|
||||||
|
String messageId
|
||||||
|
) {
|
||||||
|
Intent intent = new Intent(this, MainActivity.class)
|
||||||
|
.setAction(Intent.ACTION_VIEW)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||||
|
// Capacitor PushNotificationsPlugin gates `pushNotificationActionPerformed`
|
||||||
|
// on bundle.containsKey("google.message_id") — without it the JS listener
|
||||||
|
// silently never fires and the pendingCallActionAtom stays unset.
|
||||||
|
intent.putExtra("google.message_id", messageId != null ? messageId : "");
|
||||||
|
intent.putExtra("room_id", roomId);
|
||||||
|
intent.putExtra("notif_event_id", notifEventId);
|
||||||
|
if (callAction != null) intent.putExtra("call_action", callAction);
|
||||||
|
int flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||||
|
return PendingIntent.getActivity(this, requestCode, intent, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the JS-side createChannel in usePushNotifications.ts. Lazy creation
|
||||||
|
// from the service covers the fresh-install + killed-process race: FCM may
|
||||||
|
// deliver before the app has ever been launched (so the JS lifecycle effect
|
||||||
|
// never ran), in which case the channel doesn't exist yet and nm.notify
|
||||||
|
// would silently drop.
|
||||||
|
private void ensureMessageChannel(NotificationManager nm) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||||
|
if (nm.getNotificationChannel(CHANNEL_ID) != null) return;
|
||||||
|
Log.d(TAG, "msg: creating channel " + CHANNEL_ID);
|
||||||
|
NotificationChannel channel = new NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Messages",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
);
|
||||||
|
channel.setDescription("New chat messages and invites");
|
||||||
|
channel.enableVibration(true);
|
||||||
|
channel.enableLights(true);
|
||||||
|
nm.createNotificationChannel(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureCallChannel(NotificationManager nm) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||||
|
if (nm.getNotificationChannel(CALL_CHANNEL_ID) != null) return;
|
||||||
|
// Drop the pre-v2 channel on first creation of v2 so it doesn't linger
|
||||||
|
// in Settings → Notifications (user-visible cruft) after the bump.
|
||||||
|
if (nm.getNotificationChannel(LEGACY_CALL_CHANNEL_ID) != null) {
|
||||||
|
Log.d(TAG, "call: deleting legacy channel " + LEGACY_CALL_CHANNEL_ID);
|
||||||
|
nm.deleteNotificationChannel(LEGACY_CALL_CHANNEL_ID);
|
||||||
|
}
|
||||||
|
Log.d(TAG, "call: creating channel " + CALL_CHANNEL_ID);
|
||||||
|
NotificationChannel channel = new NotificationChannel(
|
||||||
|
CALL_CHANNEL_ID,
|
||||||
|
"Incoming calls",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
);
|
||||||
|
channel.setDescription("Incoming Vojo calls");
|
||||||
|
channel.setBypassDnd(true);
|
||||||
|
channel.setLockscreenVisibility(NotificationCompat.VISIBILITY_PUBLIC);
|
||||||
|
channel.enableVibration(true);
|
||||||
|
// Channel patterns don't auto-repeat on API 26+ (Android won't loop a
|
||||||
|
// NotificationChannel vibrationPattern the way Vibrator.vibrate(repeat=0)
|
||||||
|
// would). We pre-expand the pulse sequence to ~30s of alternating
|
||||||
|
// on/off so the device keeps vibrating for the full ring lifetime
|
||||||
|
// instead of buzzing twice and going silent.
|
||||||
|
channel.setVibrationPattern(buildRingVibrationPattern());
|
||||||
|
Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
|
||||||
|
if (ringtoneUri != null) {
|
||||||
|
AudioAttributes attrs = new AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build();
|
||||||
|
channel.setSound(ringtoneUri, attrs);
|
||||||
|
}
|
||||||
|
nm.createNotificationChannel(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleCallNotificationExpiry(
|
||||||
|
Map<String, String> data,
|
||||||
|
String tag,
|
||||||
|
int notifId
|
||||||
|
) {
|
||||||
|
long senderTs = parseLong(data.get("content_sender_ts"), -1L);
|
||||||
|
long lifetime = parseLong(data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
|
||||||
|
long baseTs = (senderTs > 0) ? senderTs : System.currentTimeMillis();
|
||||||
|
long triggerAt = baseTs + lifetime + RTC_LIFETIME_GRACE_MS;
|
||||||
|
|
||||||
|
AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
|
||||||
|
if (am == null) return;
|
||||||
|
|
||||||
|
Intent cancelIntent = new Intent(this, CallCancelReceiver.class)
|
||||||
|
.setAction(CallCancelReceiver.ACTION_CANCEL_CALL)
|
||||||
|
.putExtra(CallCancelReceiver.EXTRA_NOTIF_TAG, tag)
|
||||||
|
.putExtra(CallCancelReceiver.EXTRA_NOTIF_ID, notifId);
|
||||||
|
int flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||||
|
PendingIntent pi = PendingIntent.getBroadcast(this, notifId, cancelIntent, flags);
|
||||||
|
|
||||||
|
// setAndAllowWhileIdle is enough for a 30s-ish dismiss: exactness is
|
||||||
|
// nice-to-have but not worth the SCHEDULE_EXACT_ALARM user grant on API 34+.
|
||||||
|
// Drift of a few minutes on doze-deep devices is acceptable — the JS layer
|
||||||
|
// cancels proactively on decline / self-join anyway.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi);
|
||||||
|
} else {
|
||||||
|
am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~30 seconds of alternating 1000ms on / 500ms off — long enough to cover
|
||||||
|
// the RTC_DEFAULT_LIFETIME_MS ring window. Channels can't repeat patterns,
|
||||||
|
// so the sequence is pre-expanded.
|
||||||
|
private static long[] buildRingVibrationPattern() {
|
||||||
|
final int pulses = 20;
|
||||||
|
long[] pattern = new long[pulses * 2 + 1];
|
||||||
|
pattern[0] = 0;
|
||||||
|
for (int i = 0; i < pulses; i++) {
|
||||||
|
pattern[i * 2 + 1] = 1000L;
|
||||||
|
pattern[i * 2 + 2] = 500L;
|
||||||
|
}
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long parseLong(String s, long fallback) {
|
||||||
|
if (s == null) return fallback;
|
||||||
|
try {
|
||||||
|
return Long.parseLong(s);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String firstNonEmpty(String... values) {
|
private static String firstNonEmpty(String... values) {
|
||||||
for (String v : values) {
|
for (String v : values) {
|
||||||
if (v != null && !v.isEmpty()) return v;
|
if (v != null && !v.isEmpty()) return v;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
|
||||||
include ':capacitor-browser'
|
include ':capacitor-browser'
|
||||||
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
||||||
|
|
||||||
|
include ':capacitor-preferences'
|
||||||
|
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
|
||||||
|
|
||||||
include ':capacitor-push-notifications'
|
include ':capacitor-push-notifications'
|
||||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
|
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,10 @@
|
||||||
"push_prompt_title": "Enable notifications",
|
"push_prompt_title": "Enable notifications",
|
||||||
"push_prompt_body": "Get new messages even when Vojo is closed. You can change this anytime in Settings.",
|
"push_prompt_body": "Get new messages even when Vojo is closed. You can change this anytime in Settings.",
|
||||||
"push_prompt_later": "Not now",
|
"push_prompt_later": "Not now",
|
||||||
|
"fsi_prompt_title": "Incoming-call screen",
|
||||||
|
"fsi_prompt_body": "Let Vojo show full-screen notifications so incoming calls wake the screen and appear over the lockscreen, like WhatsApp or Telegram. Open \"Full-screen notifications\" and turn Vojo on.",
|
||||||
|
"fsi_prompt_later": "Not now",
|
||||||
|
"fsi_prompt_open": "Open settings",
|
||||||
"email_notification": "Email Notification",
|
"email_notification": "Email Notification",
|
||||||
"email_no_email": "Your account does not have any email attached.",
|
"email_no_email": "Your account does not have any email attached.",
|
||||||
"email_send_notif": "Send notification to your email.",
|
"email_send_notif": "Send notification to your email.",
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,10 @@
|
||||||
"push_prompt_title": "Включить уведомления",
|
"push_prompt_title": "Включить уведомления",
|
||||||
"push_prompt_body": "Получайте новые сообщения, даже когда Vojo закрыт. Вы можете изменить это в настройках.",
|
"push_prompt_body": "Получайте новые сообщения, даже когда Vojo закрыт. Вы можете изменить это в настройках.",
|
||||||
"push_prompt_later": "Позже",
|
"push_prompt_later": "Позже",
|
||||||
|
"fsi_prompt_title": "Экран входящего звонка",
|
||||||
|
"fsi_prompt_body": "Разрешите Vojo показывать полноэкранные уведомления — тогда входящие звонки будут будить экран и появляться поверх блокировки, как в WhatsApp или Telegram. Откройте «Уведомления поверх экрана блокировки» и включите переключатель для Vojo.",
|
||||||
|
"fsi_prompt_later": "Позже",
|
||||||
|
"fsi_prompt_open": "Открыть настройки",
|
||||||
"email_notification": "Уведомления по почте",
|
"email_notification": "Уведомления по почте",
|
||||||
"email_no_email": "К вашему аккаунту не привязана электронная почта.",
|
"email_no_email": "К вашему аккаунту не привязана электронная почта.",
|
||||||
"email_send_notif": "Отправлять уведомления на вашу почту.",
|
"email_send_notif": "Отправлять уведомления на вашу почту.",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
Overlay,
|
||||||
|
OverlayCenter,
|
||||||
|
OverlayBackdrop,
|
||||||
|
Header,
|
||||||
|
config,
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
IconButton,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Button,
|
||||||
|
} from 'folds';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { isNativePlatform } from '../../utils/capacitor';
|
||||||
|
import {
|
||||||
|
canUseFullScreenIntent,
|
||||||
|
openFullScreenIntentSettings,
|
||||||
|
} from '../../plugins/fullScreenIntent';
|
||||||
|
import { usePushEnabled } from '../../hooks/usePushNotifications';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
// Mirrors PushPermissionPrompt's cooldown. 7 days is long enough that "not now"
|
||||||
|
// isn't nagging, short enough that a user who changes their mind doesn't have
|
||||||
|
// to dig through Settings to find the toggle.
|
||||||
|
const DISMISS_KEY = 'vojo_fsi_prompt_dismissed_at';
|
||||||
|
const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const INITIAL_DELAY_MS = 1500;
|
||||||
|
|
||||||
|
const wasRecentlyDismissed = (): boolean => {
|
||||||
|
const raw = localStorage.getItem(DISMISS_KEY);
|
||||||
|
if (!raw) return false;
|
||||||
|
const ts = Number(raw);
|
||||||
|
if (!Number.isFinite(ts)) return false;
|
||||||
|
return Date.now() - ts < DISMISS_COOLDOWN_MS;
|
||||||
|
};
|
||||||
|
|
||||||
|
const markDismissed = (): void => {
|
||||||
|
localStorage.setItem(DISMISS_KEY, String(Date.now()));
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FullScreenIntentPrompt() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pushEnabled = usePushEnabled();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only relevant when push is on — FSI wakeup only matters if we're actually
|
||||||
|
// going to show CallStyle notifications. Showing this before push is enabled
|
||||||
|
// would be out-of-context: user doesn't yet know what "incoming call screen"
|
||||||
|
// means in our UX. The PushPermissionPrompt comes first.
|
||||||
|
if (!isNativePlatform()) return undefined;
|
||||||
|
if (!pushEnabled) {
|
||||||
|
setVisible(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (wasRecentlyDismissed()) return undefined;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
canUseFullScreenIntent().then((allowed) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (allowed) return;
|
||||||
|
// Delay matches PushPermissionPrompt so the two never try to stack on
|
||||||
|
// top of each other at exact startup — PushPermissionPrompt goes first
|
||||||
|
// since its effect depends on `status === 'prompt'`; by the time that's
|
||||||
|
// resolved to 'granted' (and pushEnabled flips true), the PushPrompt is
|
||||||
|
// already gone and this one has room.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!cancelled) setVisible(true);
|
||||||
|
}, INITIAL_DELAY_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [pushEnabled]);
|
||||||
|
|
||||||
|
const handleLater = useCallback(() => {
|
||||||
|
markDismissed();
|
||||||
|
setVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEnable = useCallback(() => {
|
||||||
|
// Opens Settings; the user returns to the app via the back button. We don't
|
||||||
|
// re-check canUseFullScreenIntent() on resume because the prompt will
|
||||||
|
// simply not re-show (7-day cooldown would apply if we markDismissed —
|
||||||
|
// we deliberately DO NOT mark dismissed here so if the user declines in
|
||||||
|
// Settings or forgets, the next startup still reminds them).
|
||||||
|
openFullScreenIntentSettings().catch(() => {
|
||||||
|
/* plugin missing — nothing to do */
|
||||||
|
});
|
||||||
|
setVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: handleLater,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface">
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">{t('Settings.fsi_prompt_title')}</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={handleLater} radii="300">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Text priority="400">{t('Settings.fsi_prompt_body')}</Text>
|
||||||
|
<Box direction="Row" gap="200" justifyContent="End">
|
||||||
|
<Button variant="Secondary" fill="Soft" onClick={handleLater}>
|
||||||
|
<Text size="B400">{t('Settings.fsi_prompt_later')}</Text>
|
||||||
|
</Button>
|
||||||
|
<Button variant="Primary" onClick={handleEnable}>
|
||||||
|
<Text size="B400">{t('Settings.fsi_prompt_open')}</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/components/full-screen-intent-prompt/index.ts
Normal file
1
src/app/components/full-screen-intent-prompt/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './FullScreenIntentPrompt';
|
||||||
60
src/app/hooks/useDismissNativeCallNotifications.ts
Normal file
60
src/app/hooks/useDismissNativeCallNotifications.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||||
|
import { isNativePlatform } from '../utils/capacitor';
|
||||||
|
|
||||||
|
// When the in-app incomingCallsAtom drops an entry (user accepted, declined,
|
||||||
|
// other device joined, lifetime expired), also clear the CallStyle notification
|
||||||
|
// the native service posted for that room. The AlarmManager fallback in the
|
||||||
|
// Java service handles killed-process dismiss on lifetime expiry; this hook
|
||||||
|
// covers the live-client paths where JS knows the truth sooner than the alarm.
|
||||||
|
export const useDismissNativeCallNotifications = (): void => {
|
||||||
|
const incoming = useAtomValue(incomingCallsAtom);
|
||||||
|
const prevRoomsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextRooms = new Set<string>();
|
||||||
|
incoming.forEach((call) => nextRooms.add(call.roomId));
|
||||||
|
|
||||||
|
const dropped: string[] = [];
|
||||||
|
prevRoomsRef.current.forEach((roomId) => {
|
||||||
|
if (!nextRooms.has(roomId)) dropped.push(roomId);
|
||||||
|
});
|
||||||
|
prevRoomsRef.current = nextRooms;
|
||||||
|
|
||||||
|
if (dropped.length === 0) return;
|
||||||
|
if (!isNativePlatform()) return;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const { PushNotifications } = await import('@capacitor/push-notifications');
|
||||||
|
// Shape mirrors VojoFirebaseMessagingService.showIncomingCallNotification:
|
||||||
|
// tag = "call_" + roomId, id = tag.hashCode(). The Android Capacitor
|
||||||
|
// plugin reads id via JSObject.getInteger (PushNotificationsPlugin.java
|
||||||
|
// line 166), so the id must go over the bridge as a number — the TS
|
||||||
|
// type declares string, hence the cast.
|
||||||
|
const notifications = dropped.map((roomId) => {
|
||||||
|
const tag = `call_${roomId}`;
|
||||||
|
return { id: javaStringHashCode(tag), tag };
|
||||||
|
});
|
||||||
|
await PushNotifications.removeDeliveredNotifications({
|
||||||
|
notifications: notifications as unknown as Parameters<
|
||||||
|
typeof PushNotifications.removeDeliveredNotifications
|
||||||
|
>[0]['notifications'],
|
||||||
|
}).catch(() => {
|
||||||
|
/* best-effort — alarm fallback still dismisses at lifetime expiry */
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, [incoming]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reproduces java.lang.String#hashCode so JS-side ids match the tag ids the
|
||||||
|
// Java service computed. Must stay in sync with CallCancelReceiver / the
|
||||||
|
// tag.hashCode() call in VojoFirebaseMessagingService.
|
||||||
|
function javaStringHashCode(s: string): number {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i += 1) {
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
52
src/app/hooks/usePendingCallActionConsumer.ts
Normal file
52
src/app/hooks/usePendingCallActionConsumer.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { App } from '@capacitor/app';
|
||||||
|
import { pendingCallActionAtom } from '../state/pendingCallAction';
|
||||||
|
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||||
|
import { useDmCallStart } from './useDmCallStart';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { isNativePlatform } from '../utils/capacitor';
|
||||||
|
|
||||||
|
// Consumes pending call actions emitted by the native Android push-action
|
||||||
|
// listener (see usePushNotifications.ts). Must be mounted inside CallEmbedProvider
|
||||||
|
// so useDmCallStart can reach the embed atom.
|
||||||
|
export const usePendingCallActionConsumer = (): void => {
|
||||||
|
const pending = useAtomValue(pendingCallActionAtom);
|
||||||
|
const setPending = useSetAtom(pendingCallActionAtom);
|
||||||
|
const setIncoming = useSetAtom(incomingCallsAtom);
|
||||||
|
const startDmCall = useDmCallStart();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pending) return;
|
||||||
|
if (pending.kind === 'answer') {
|
||||||
|
// In-app strip (if any) is stale after accept — drop it so the overlay
|
||||||
|
// can own the UX without a parallel decline button lingering.
|
||||||
|
setIncoming({ type: 'REMOVE_BY_ROOM', roomId: pending.roomId });
|
||||||
|
startDmCall(pending.roomId);
|
||||||
|
} else {
|
||||||
|
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId: pending.notifEventId });
|
||||||
|
// Fire-and-minimize: dispatch the decline then minimize the app once the
|
||||||
|
// request settles (success OR failure). Minimizing before sendRtcDecline
|
||||||
|
// resolves risks the WebView getting paused mid-request on slower devices;
|
||||||
|
// waiting for settlement gives the network call the tick it needs.
|
||||||
|
// Lockscreen flow still requires unlock — known MVP compromise; a proper
|
||||||
|
// BroadcastReceiver-based decline is tracked in techdebt 5.33.
|
||||||
|
const minimize = () => {
|
||||||
|
if (!isNativePlatform()) return;
|
||||||
|
App.minimizeApp().catch(() => {
|
||||||
|
/* minimize not supported / already in background */
|
||||||
|
});
|
||||||
|
};
|
||||||
|
mx.sendRtcDecline(pending.roomId, pending.notifEventId).then(
|
||||||
|
() => minimize(),
|
||||||
|
(err: unknown) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[call] sendRtcDecline (from push action) failed', err);
|
||||||
|
minimize();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setPending(undefined);
|
||||||
|
}, [pending, setPending, setIncoming, startDmCall, mx]);
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useClientConfig } from './useClientConfig';
|
import { useClientConfig } from './useClientConfig';
|
||||||
import { isNativePlatform } from '../utils/capacitor';
|
import { isNativePlatform } from '../utils/capacitor';
|
||||||
|
|
@ -19,7 +20,8 @@ import {
|
||||||
unregisterPusher,
|
unregisterPusher,
|
||||||
urlBase64ToUint8Array,
|
urlBase64ToUint8Array,
|
||||||
} from '../utils/push';
|
} from '../utils/push';
|
||||||
import { getHomeRoomPath, getInboxInvitesPath } from '../pages/pathUtils';
|
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pages/pathUtils';
|
||||||
|
import { pendingCallActionAtom } from '../state/pendingCallAction';
|
||||||
|
|
||||||
const noop = (): void => undefined;
|
const noop = (): void => undefined;
|
||||||
|
|
||||||
|
|
@ -272,6 +274,7 @@ export function usePushNotificationsLifecycle(): void {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const clientConfig = useClientConfig();
|
const clientConfig = useClientConfig();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const setPendingCallAction = useSetAtom(pendingCallActionAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNativePlatform()) return;
|
if (isNativePlatform()) return;
|
||||||
|
|
@ -314,10 +317,72 @@ export function usePushNotificationsLifecycle(): void {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isNativePlatform()) return undefined;
|
if (!isNativePlatform()) return undefined;
|
||||||
|
|
||||||
let cleanup: (() => void) | undefined;
|
let cancelled = false;
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
const cfg = clientConfig.push;
|
||||||
|
|
||||||
|
const pnPromise = import('@capacitor/push-notifications');
|
||||||
|
|
||||||
|
// Attach the action listener on its own promise chain — no other awaits
|
||||||
|
// before it — so cold-start Decline/Answer events (fired by the native
|
||||||
|
// PushNotifications plugin via handleOnNewIntent the moment MainActivity
|
||||||
|
// boots) have a listener waiting. Sequential awaits behind channel
|
||||||
|
// creation / register() added ~100-300ms during which the event could be
|
||||||
|
// dropped entirely on a killed-process launch.
|
||||||
|
pnPromise
|
||||||
|
.then(({ PushNotifications }) => {
|
||||||
|
if (cancelled) return null;
|
||||||
|
return PushNotifications.addListener(
|
||||||
|
'pushNotificationActionPerformed',
|
||||||
|
(action) => {
|
||||||
|
const data = action.notification.data as {
|
||||||
|
room_id?: string;
|
||||||
|
call_action?: 'answer' | 'decline';
|
||||||
|
notif_event_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Native CallStyle Answer → open the room and queue an auto-join via
|
||||||
|
// pendingCallActionAtom. The consumer hook picks it up once the
|
||||||
|
// CallEmbedProvider tree is mounted. DM rooms live in the Direct tab
|
||||||
|
// route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs
|
||||||
|
// it doesn't have in its left-rail, hence the DM path here.
|
||||||
|
if (data.call_action === 'answer' && data.room_id) {
|
||||||
|
navigate(getDirectRoomPath(data.room_id));
|
||||||
|
setPendingCallAction({ kind: 'answer', roomId: data.room_id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decline: queue the action and let the consumer (inside
|
||||||
|
// CallEmbedProvider) fire sendRtcDecline and then minimize the app.
|
||||||
|
// Doing the minimize here would race the setAtom — setAtom schedules
|
||||||
|
// a render-tick, minimize is synchronous, so we'd close the WebView
|
||||||
|
// before the consumer could pick up the atom and send the decline.
|
||||||
|
if (data.call_action === 'decline' && data.room_id && data.notif_event_id) {
|
||||||
|
setPendingCallAction({
|
||||||
|
kind: 'decline',
|
||||||
|
roomId: data.room_id,
|
||||||
|
notifEventId: data.notif_event_id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.room_id) navigate(getHomeRoomPath(data.room_id));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then((h) => {
|
||||||
|
if (!h) return;
|
||||||
|
cleanups.push(() => {
|
||||||
|
h.remove().catch(noop);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(noop);
|
||||||
|
|
||||||
|
// Channel + registration listener + token-kickoff on a parallel chain —
|
||||||
|
// order doesn't matter for these, they just need to happen eventually.
|
||||||
(async () => {
|
(async () => {
|
||||||
const { PushNotifications } = await import('@capacitor/push-notifications');
|
const { PushNotifications } = await pnPromise;
|
||||||
const cfg = clientConfig.push;
|
if (cancelled) return;
|
||||||
|
|
||||||
// Android 8+ requires a notification channel before any system notification can appear,
|
// Android 8+ requires a notification channel before any system notification can appear,
|
||||||
// and apps with no channels aren't listed in Settings → Notifications. Sygnal sends
|
// and apps with no channels aren't listed in Settings → Notifications. Sygnal sends
|
||||||
|
|
@ -338,6 +403,13 @@ export function usePushNotificationsLifecycle(): void {
|
||||||
/* channel may already exist */
|
/* channel may already exist */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The call channel (vojo_calls_v2) is created lazily from the native
|
||||||
|
// VojoFirebaseMessagingService.ensureCallChannel() on first ring. Creating
|
||||||
|
// it here from JS would race with Java — whichever call wins freezes the
|
||||||
|
// vibration pattern / sound for the lifetime of the channel (immutable
|
||||||
|
// after creation on API 26+), and the Capacitor API can't set a long
|
||||||
|
// repeating vibrationPattern. Let Java own this channel exclusively.
|
||||||
|
|
||||||
// Persistent listener: update pusher on the server with the (possibly rotated) token.
|
// Persistent listener: update pusher on the server with the (possibly rotated) token.
|
||||||
// MUST NOT call the full register() flow here — that would call
|
// MUST NOT call the full register() flow here — that would call
|
||||||
// PushNotifications.register() again and re-fire this listener → infinite loop.
|
// PushNotifications.register() again and re-fire this listener → infinite loop.
|
||||||
|
|
@ -349,14 +421,13 @@ export function usePushNotificationsLifecycle(): void {
|
||||||
.then((ids) => savePusherIds(ids))
|
.then((ids) => savePusherIds(ids))
|
||||||
.catch(noop);
|
.catch(noop);
|
||||||
});
|
});
|
||||||
|
if (cancelled) {
|
||||||
const actionHandle = await PushNotifications.addListener(
|
regHandle.remove().catch(noop);
|
||||||
'pushNotificationActionPerformed',
|
} else {
|
||||||
(action) => {
|
cleanups.push(() => {
|
||||||
const roomId = (action.notification.data as { room_id?: string })?.room_id;
|
regHandle.remove().catch(noop);
|
||||||
if (roomId) navigate(getHomeRoomPath(roomId));
|
});
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
// Display is handled entirely by the native VojoFirebaseMessagingService
|
// Display is handled entirely by the native VojoFirebaseMessagingService
|
||||||
// (which fires for both backgrounded and killed process states via FCM's
|
// (which fires for both backgrounded and killed process states via FCM's
|
||||||
|
|
@ -377,15 +448,13 @@ export function usePushNotificationsLifecycle(): void {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup = () => {
|
|
||||||
regHandle.remove();
|
|
||||||
actionHandle.remove();
|
|
||||||
};
|
|
||||||
})().catch(noop);
|
})().catch(noop);
|
||||||
|
|
||||||
return () => cleanup?.();
|
return () => {
|
||||||
}, [navigate, mx, clientConfig]);
|
cancelled = true;
|
||||||
|
cleanups.forEach((c) => c());
|
||||||
|
};
|
||||||
|
}, [navigate, mx, clientConfig, setPendingCallAction]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { isPushEnabled, getPushPlatform } from '../utils/push';
|
export { isPushEnabled, getPushPlatform } from '../utils/push';
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@
|
||||||
// Mounted in Router.tsx inside `CallEmbedProvider`, rendered right before
|
// Mounted in Router.tsx inside `CallEmbedProvider`, rendered right before
|
||||||
// `CallStatusRenderer` so the strip stacks above the in-call pill.
|
// `CallStatusRenderer` so the strip stacks above the in-call pill.
|
||||||
//
|
//
|
||||||
|
// On native Android the OS CallStyle notification (see
|
||||||
|
// VojoFirebaseMessagingService) owns the incoming-call UX end-to-end: heads-up,
|
||||||
|
// ringtone, Answer/Decline buttons, full-screen wakeup. Rendering the in-app
|
||||||
|
// strip in parallel just duplicates UI and plays a second ringtone on top of
|
||||||
|
// the system one — so the renderer is a no-op on native.
|
||||||
|
//
|
||||||
// KNOWN GAP §5.17: if the browser blocks `audio.play()` (cold page load, no
|
// KNOWN GAP §5.17: if the browser blocks `audio.play()` (cold page load, no
|
||||||
// user gesture yet), the ring is silent — strip is still visible but user may
|
// user gesture yet), the ring is silent — strip is still visible but user may
|
||||||
// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications) is
|
// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications) is
|
||||||
|
|
@ -14,6 +20,7 @@ import { Box } from 'folds';
|
||||||
import { incomingCallsAtom } from '../state/incomingCalls';
|
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
import { IncomingCallStrip } from '../features/call-status';
|
import { IncomingCallStrip } from '../features/call-status';
|
||||||
|
import { isNativePlatform } from '../utils/capacitor';
|
||||||
// eslint-disable-next-line import/no-relative-packages
|
// eslint-disable-next-line import/no-relative-packages
|
||||||
import RingSoundOgg from '../../../public/sound/ring.ogg';
|
import RingSoundOgg from '../../../public/sound/ring.ogg';
|
||||||
// eslint-disable-next-line import/no-relative-packages
|
// eslint-disable-next-line import/no-relative-packages
|
||||||
|
|
@ -25,11 +32,12 @@ export function IncomingCallStripRenderer() {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
const hasIncoming = incoming.size > 0;
|
const hasIncoming = incoming.size > 0;
|
||||||
|
const suppress = isNativePlatform();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
if (hasIncoming) {
|
if (hasIncoming && !suppress) {
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
audio.play().catch(() => {
|
audio.play().catch(() => {
|
||||||
// autoplay blocked — strip UI still visible
|
// autoplay blocked — strip UI still visible
|
||||||
|
|
@ -38,7 +46,9 @@ export function IncomingCallStripRenderer() {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
}
|
}
|
||||||
}, [hasIncoming]);
|
}, [hasIncoming, suppress]);
|
||||||
|
|
||||||
|
if (suppress) return null;
|
||||||
|
|
||||||
const entries = Array.from(incoming.values());
|
const entries = Array.from(incoming.values());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,11 +72,15 @@ import { CallStatusRenderer } from './CallStatusRenderer';
|
||||||
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
||||||
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
|
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
|
||||||
import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup';
|
import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup';
|
||||||
|
import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer';
|
||||||
|
import { useDismissNativeCallNotifications } from '../hooks/useDismissNativeCallNotifications';
|
||||||
import { IncomingCallStripRenderer } from './IncomingCallStripRenderer';
|
import { IncomingCallStripRenderer } from './IncomingCallStripRenderer';
|
||||||
|
|
||||||
function IncomingCallsFeature() {
|
function IncomingCallsFeature() {
|
||||||
useIncomingRtcNotifications();
|
useIncomingRtcNotifications();
|
||||||
useCallerAutoHangup();
|
useCallerAutoHangup();
|
||||||
|
usePendingCallActionConsumer();
|
||||||
|
useDismissNativeCallNotifications();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications';
|
import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications';
|
||||||
import { PushPermissionPrompt } from '../../components/push-permission-prompt';
|
import { PushPermissionPrompt } from '../../components/push-permission-prompt';
|
||||||
|
import { FullScreenIntentPrompt } from '../../components/full-screen-intent-prompt';
|
||||||
import { useAndroidBackButton } from '../../hooks/useAndroidBackButton';
|
import { useAndroidBackButton } from '../../hooks/useAndroidBackButton';
|
||||||
|
|
||||||
function SystemEmojiFeature() {
|
function SystemEmojiFeature() {
|
||||||
|
|
@ -166,6 +167,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
<PushNotificationsFeature />
|
<PushNotificationsFeature />
|
||||||
<PushPermissionPrompt />
|
<PushPermissionPrompt />
|
||||||
|
<FullScreenIntentPrompt />
|
||||||
<AndroidBackButtonFeature />
|
<AndroidBackButtonFeature />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
36
src/app/plugins/fullScreenIntent.ts
Normal file
36
src/app/plugins/fullScreenIntent.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { registerPlugin } from '@capacitor/core';
|
||||||
|
import { isNativePlatform } from '../utils/capacitor';
|
||||||
|
|
||||||
|
// Bridge to the native FullScreenIntentPlugin (see
|
||||||
|
// android/app/src/main/java/chat/vojo/app/FullScreenIntentPlugin.java).
|
||||||
|
// Web and iOS builds get the no-op fallback below — FSI opt-in only exists on Android 14+.
|
||||||
|
export interface FullScreenIntentPlugin {
|
||||||
|
canUseFullScreenIntent(): Promise<{ value: boolean }>;
|
||||||
|
openSettings(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FullScreenIntent = registerPlugin<FullScreenIntentPlugin>('FullScreenIntent', {
|
||||||
|
web: {
|
||||||
|
canUseFullScreenIntent: async () => ({ value: true }),
|
||||||
|
openSettings: async () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const canUseFullScreenIntent = async (): Promise<boolean> => {
|
||||||
|
if (!isNativePlatform()) return true;
|
||||||
|
try {
|
||||||
|
const { value } = await FullScreenIntent.canUseFullScreenIntent();
|
||||||
|
return value;
|
||||||
|
} catch {
|
||||||
|
// Plugin not yet available (e.g., old build installed before the plugin shipped).
|
||||||
|
// Fail "allowed" so we don't nag users on older APKs — the permission is a no-op
|
||||||
|
// on APIs that don't check it, and on APIs that do the ring itself will make the
|
||||||
|
// problem obvious (no wakeup) and the user can find the setting themselves.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openFullScreenIntentSettings = async (): Promise<void> => {
|
||||||
|
if (!isNativePlatform()) return;
|
||||||
|
await FullScreenIntent.openSettings();
|
||||||
|
};
|
||||||
11
src/app/state/pendingCallAction.ts
Normal file
11
src/app/state/pendingCallAction.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
// Bridge between the native Capacitor push-action listener (which fires outside
|
||||||
|
// any React render context) and the in-app call flow. The listener sets the
|
||||||
|
// atom; usePendingCallActionConsumer — mounted inside CallEmbedProvider — reads
|
||||||
|
// it and triggers useDmCallStart / mx.sendRtcDecline.
|
||||||
|
export type PendingCallAction =
|
||||||
|
| { kind: 'answer'; roomId: string }
|
||||||
|
| { kind: 'decline'; roomId: string; notifEventId: string };
|
||||||
|
|
||||||
|
export const pendingCallActionAtom = atom<PendingCallAction | undefined>(undefined);
|
||||||
Loading…
Add table
Reference in a new issue