diff --git a/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java b/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java
new file mode 100644
index 00000000..76e45800
--- /dev/null
+++ b/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java
@@ -0,0 +1,441 @@
+package chat.vojo.app;
+
+import android.animation.ValueAnimator;
+import android.app.Activity;
+import android.app.KeyguardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.PowerManager;
+import android.util.Log;
+import android.util.TypedValue;
+import android.view.HapticFeedbackConstants;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.WindowManager;
+import android.view.animation.LinearInterpolator;
+import android.view.animation.OvershootInterpolator;
+import android.widget.ImageButton;
+import android.widget.TextView;
+
+/**
+ * Full-screen incoming-call screen, raised over the lockscreen by the CallStyle
+ * notification's full-screen intent (Phase C, docs/plans/telecom_migration.md).
+ *
+ * WHY NATIVE. Media lives in the Element Call WebView, which can't paint over a
+ * locked screen fast enough to feel like a real call. This Activity is plain
+ * native views (DAWN palette, see res/values/colors.xml) so the ring UI appears
+ * instantly. Answer/Decline mirror the CallStyle notification actions exactly:
+ * - Answer → launch MainActivity with call_action=answer (the existing
+ * pendingCallAction path joins the WebView), dismissing the keyguard first.
+ * - Decline → broadcast CallDeclineReceiver (native decline; cancels the ring,
+ * tombstones it, sends m.rtc.decline) without booting the WebView.
+ *
+ * LIFECYCLE. Self-finishes when the ring is no longer live: the FCM service calls
+ * {@link #finishFor(String)} from removeIncomingRing (answer / decline / expiry /
+ * supersede). A 60s safety timeout backstops a missed signal. Back press just
+ * dismisses the screen; the ring lives on as the CallStyle notification.
+ *
+ * ANSWER. Hands off to MainActivity (the WebView call surface) carrying
+ * over_lock = "device was locked". On a locked device we do NOT force an unlock:
+ * MainActivity shows over the keyguard and audio joins over the lock (element-x
+ * pattern); the JS over-lock screen covers the rest of the app for privacy.
+ */
+public class IncomingCallActivity extends Activity {
+
+ private static final String TAG = "VojoIncomingCall";
+ // Coarse backstop only: the ring's expiry alarm (CallCancelReceiver →
+ // removeIncomingRing → finishFor) is the real teardown. 60s comfortably
+ // outlives the ~30s ring so we never finish a still-live ring early; on a
+ // missed finishFor the screen/wakelock linger at most ~30s past ring death.
+ private static final long RING_SAFETY_TIMEOUT_MS = 60_000L;
+
+ // The single visible incoming screen (DM rings are one-at-a-time). Used by
+ // finishFor so the FCM service can dismiss it the moment its ring ends.
+ private static volatile IncomingCallActivity current;
+
+ private String roomId;
+ private String notifEventId;
+ private String messageId;
+ private boolean acted = false;
+
+ private PowerManager.WakeLock wakeLock;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private final Runnable safetyFinish = this::finish;
+
+ // Looping "slide up to answer" chevron hint; suppressed while a drag is live.
+ private ValueAnimator hintAnimator;
+ private volatile boolean dragHintSuppressed;
+
+ @Override
+ protected void attachBaseContext(Context base) {
+ // Render the ring screen in the in-app language (matches CallStyle / strip /
+ // over-lock), not the device locale — the full-screen ring is the most
+ // prominent call surface and was the only one ignoring the language pick.
+ super.attachBaseContext(PushStrings.wrap(base));
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Show over the lockscreen and wake the screen. setShowWhenLocked /
+ // setTurnScreenOn are the API-27+ way; window flags cover 24-26 (and
+ // FLAG_KEEP_SCREEN_ON applies on all levels).
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ setShowWhenLocked(true);
+ setTurnScreenOn(true);
+ } else {
+ getWindow().addFlags(
+ WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
+ | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
+ }
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+
+ setContentView(R.layout.activity_incoming_call);
+ // Validate + bind BEFORE acquiring the wakelock / registering as current /
+ // arming the timer, so a malformed launch bails cleanly with nothing to undo.
+ if (!bindFromIntent(getIntent())) {
+ finish();
+ return;
+ }
+
+ View answerBtn = findViewById(R.id.call_answer);
+ View declineBtn = findViewById(R.id.call_decline);
+ answerBtn.setOnClickListener(v -> onAnswer());
+ declineBtn.setOnClickListener(v -> onDecline());
+ // Drag-to-activate: a plain tap fires the action immediately; the moment a
+ // drag begins, the button follows the finger, a radial glow charges up
+ // with the drag progress, and the action commits once dragged past its own
+ // area (releasing short of that springs back, cancelling). Both buttons get
+ // their own listener instance (independent per-gesture state — safe under
+ // multitouch). performClick() routes the commit through the click listener
+ // above, so the keyboard/TalkBack click path stays live.
+ answerBtn.setOnTouchListener(dragToCommit(findViewById(R.id.answer_glow)));
+ declineBtn.setOnTouchListener(dragToCommit(findViewById(R.id.decline_glow)));
+ startDragHint(findViewById(R.id.answer_hint));
+
+ acquireRingWakeLock();
+ current = this;
+ handler.postDelayed(safetyFinish, RING_SAFETY_TIMEOUT_MS);
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ // A superseding ring re-used this task (singleTask) — rebind to it.
+ setIntent(intent);
+ if (!bindFromIntent(intent)) {
+ finish();
+ return;
+ }
+ acted = false;
+ // Reset the bounded wakelock + safety timer to the new ring's budget
+ // (setReferenceCounted(false) → a fresh acquire just re-arms the timeout).
+ acquireRingWakeLock();
+ handler.removeCallbacks(safetyFinish);
+ handler.postDelayed(safetyFinish, RING_SAFETY_TIMEOUT_MS);
+ }
+
+ // Returns false (and binds nothing usable) when the launch lacks a room id.
+ private boolean bindFromIntent(Intent intent) {
+ roomId = intent != null ? intent.getStringExtra("room_id") : null;
+ notifEventId = intent != null ? intent.getStringExtra("notif_event_id") : null;
+ messageId = intent != null ? intent.getStringExtra("google.message_id") : null;
+ if (roomId == null || roomId.isEmpty()) {
+ Log.w(TAG, "missing room_id, finishing");
+ return false;
+ }
+ String callerName = intent.getStringExtra("caller_name");
+ if (callerName == null || callerName.trim().isEmpty()) callerName = "Vojo";
+
+ ((TextView) findViewById(R.id.call_name)).setText(callerName);
+ ((TextView) findViewById(R.id.call_subtitle)).setText(R.string.call_incoming);
+ ((TextView) findViewById(R.id.call_avatar)).setText(initialOf(callerName));
+ return true;
+ }
+
+ private static String initialOf(String name) {
+ String trimmed = name.trim();
+ if (trimmed.isEmpty()) return "?";
+ int cp = trimmed.codePointAt(0);
+ return new String(Character.toChars(cp)).toUpperCase();
+ }
+
+ private void onAnswer() {
+ if (acted) return;
+ acted = true;
+ // Mark the Telecom session answered NATIVELY and immediately (M9). JS only
+ // calls telecomCall.answer() seconds later (after MainActivity boots), and
+ // in that cold-answer window an expiry alarm / remote disconnect would see
+ // !isAnswered() and tear down the call the user just accepted. answerCall
+ // is room-guarded + idempotent, so the later JS answer is harmless.
+ try {
+ VojoCallsManager.getInstance(getApplicationContext()).answerCall(roomId, false);
+ } catch (Throwable t) {
+ Log.w(TAG, "answer: native answerCall threw", t);
+ }
+ // Hand off to the WebView call surface (MainActivity). We do NOT force an
+ // unlock: if the device is locked we tell MainActivity to show over the
+ // keyguard (over_lock), element-x style — the call WebView loads + audio
+ // joins over the lock, and the JS over-lock screen covers the rest of the
+ // app. The user talks without unlocking; they unlock only to use the app.
+ boolean locked = isKeyguardLockedNow();
+ // Same intent shape as the CallStyle Answer action (buildActionPI):
+ // Capacitor's pushNotificationActionPerformed fires on google.message_id,
+ // the JS consumer then runs telecomCall.answer() + switchOrStartDmCall.
+ Intent answer = new Intent(this, MainActivity.class)
+ .setAction(Intent.ACTION_VIEW)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
+ | Intent.FLAG_ACTIVITY_SINGLE_TOP
+ | Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ .putExtra("google.message_id", messageId != null ? messageId : "")
+ .putExtra("room_id", roomId)
+ .putExtra("notif_event_id", notifEventId)
+ .putExtra("call_action", "answer")
+ .putExtra("over_lock", locked);
+ try {
+ startActivity(answer);
+ } catch (Throwable t) {
+ Log.e(TAG, "answer: startActivity failed", t);
+ }
+ finish();
+ }
+
+ private boolean isKeyguardLockedNow() {
+ KeyguardManager km = getSystemService(KeyguardManager.class);
+ return km != null && km.isKeyguardLocked();
+ }
+
+ // ── Drag-to-activate gesture (slide a ring button to commit) ──
+
+ // A fresh stateful OnTouchListener per call so two buttons never share
+ // per-gesture state. Tap (no drag past touch-slop) → performClick() on UP.
+ // Drag → the disc tracks the finger 1:1 while a radial [glow] behind it
+ // charges up (alpha + scale) CONTINUOUSLY with the drag progress (0→1 over
+ // the commit distance), and the disc scales up with it. Crossing the commit
+ // threshold fires a one-shot haptic; releasing past it performs the click,
+ // releasing short of it springs everything back and does nothing. Values are
+ // set directly (not via animate()) during the drag so the glow stays glued to
+ // the finger with zero latency; animate() is used only for the press lift and
+ // the spring-back.
+ private View.OnTouchListener dragToCommit(final View glow) {
+ final int touchSlop = ViewConfiguration.get(this).getScaledTouchSlop();
+ return new View.OnTouchListener() {
+ private float downX;
+ private float downY;
+ private boolean dragging;
+ private boolean pastThreshold;
+
+ private float commitThreshold(View v) {
+ // Past roughly the button's own radius+ → "dragged out of its area".
+ return Math.max(touchSlop * 4f, v.getWidth() * 0.55f);
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent e) {
+ switch (e.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ downX = e.getRawX();
+ downY = e.getRawY();
+ dragging = false;
+ pastThreshold = false;
+ v.animate().scaleX(1.06f).scaleY(1.06f).setDuration(120).start();
+ return true;
+ case MotionEvent.ACTION_MOVE: {
+ float dx = e.getRawX() - downX;
+ float dy = e.getRawY() - downY;
+ double dist = Math.hypot(dx, dy);
+ if (!dragging && dist > touchSlop) {
+ dragging = true;
+ dragHintSuppressed = true;
+ }
+ if (dragging) {
+ float threshold = commitThreshold(v);
+ float p = (float) Math.min(1.0, dist / threshold);
+ v.setTranslationX(dx);
+ v.setTranslationY(dy);
+ v.setScaleX(1.06f + 0.16f * p);
+ v.setScaleY(1.06f + 0.16f * p);
+ if (glow != null) {
+ glow.setAlpha(0.18f + 0.82f * p);
+ glow.setScaleX(0.58f + 0.52f * p);
+ glow.setScaleY(0.58f + 0.52f * p);
+ }
+ boolean past = p >= 1f;
+ if (past != pastThreshold) {
+ pastThreshold = past;
+ if (past) {
+ v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
+ }
+ }
+ }
+ return true;
+ }
+ case MotionEvent.ACTION_UP: {
+ float dx = e.getRawX() - downX;
+ float dy = e.getRawY() - downY;
+ boolean commit = !dragging || Math.hypot(dx, dy) >= commitThreshold(v);
+ if (commit) {
+ // Tap or a committed drag → fire. The activity finishes
+ // immediately, so no need to settle the visuals.
+ v.performClick();
+ } else {
+ springBackDragButton(v, glow);
+ }
+ return true;
+ }
+ case MotionEvent.ACTION_CANCEL:
+ springBackDragButton(v, glow);
+ return true;
+ default:
+ return false;
+ }
+ }
+ };
+ }
+
+ // Spring the disc back to rest (overshoot) and fade the glow out. Re-enables
+ // the idle drag hint.
+ private void springBackDragButton(View v, View glow) {
+ v.animate()
+ .translationX(0)
+ .translationY(0)
+ .scaleX(1f)
+ .scaleY(1f)
+ .setInterpolator(new OvershootInterpolator(2.2f))
+ .setDuration(340)
+ .start();
+ if (glow != null) {
+ glow.animate().alpha(0f).scaleX(0.58f).scaleY(0.58f).setDuration(220).start();
+ }
+ dragHintSuppressed = false;
+ }
+
+ // Looping "slide up to answer" affordance: the chevrons rise and fade in a
+ // 1.5s cycle. Suppressed (held invisible) while a drag is active so the hint
+ // never fights the live gesture.
+ private void startDragHint(final View hint) {
+ if (hint == null) return;
+ hint.setAlpha(0f);
+ final float from = dp(10f);
+ final float to = dp(-8f);
+ ValueAnimator a = ValueAnimator.ofFloat(0f, 1f);
+ a.setDuration(1500);
+ a.setRepeatCount(ValueAnimator.INFINITE);
+ a.setInterpolator(new LinearInterpolator());
+ a.addUpdateListener(an -> {
+ if (dragHintSuppressed) {
+ hint.setAlpha(0f);
+ return;
+ }
+ float f = (float) an.getAnimatedValue();
+ hint.setTranslationY(from + (to - from) * f);
+ float alpha;
+ if (f < 0.35f) {
+ alpha = f / 0.35f;
+ } else if (f > 0.75f) {
+ alpha = (1f - f) / 0.25f;
+ } else {
+ alpha = 1f;
+ }
+ hint.setAlpha(alpha * 0.85f);
+ });
+ a.start();
+ hintAnimator = a;
+ }
+
+ private float dp(float value) {
+ return TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, value, getResources().getDisplayMetrics());
+ }
+
+ private void onDecline() {
+ if (acted) return;
+ acted = true;
+ // Broadcast the native decline (cancels the ring + tombstones it + sends
+ // m.rtc.decline), identical to the CallStyle Decline action.
+ String tag = "call_" + roomId;
+ int notifId = tag.hashCode();
+ if (notifId == Integer.MIN_VALUE) notifId += 1;
+ Intent decline = new Intent(this, CallDeclineReceiver.class)
+ .setAction(CallDeclineReceiver.ACTION_DECLINE_CALL)
+ .putExtra(CallDeclineReceiver.EXTRA_ROOM_ID, roomId)
+ .putExtra(CallDeclineReceiver.EXTRA_NOTIF_EVENT_ID, notifEventId)
+ .putExtra(CallDeclineReceiver.EXTRA_NOTIF_TAG, tag)
+ .putExtra(CallDeclineReceiver.EXTRA_NOTIF_ID, notifId);
+ try {
+ sendBroadcast(decline);
+ } catch (Throwable t) {
+ Log.e(TAG, "decline: sendBroadcast failed", t);
+ }
+ finish();
+ }
+
+ @Override
+ public void onBackPressed() {
+ // Back is a soft dismiss of the screen only: the ring stays live as the
+ // CallStyle notification AND its Telecom session + phoneCall FGS keep
+ // running (the ring ends only on a real terminal transition —
+ // answer/decline/expiry — via removeIncomingRing). The user can still
+ // answer/decline from the shade.
+ finish();
+ }
+
+ @Override
+ protected void onDestroy() {
+ handler.removeCallbacksAndMessages(null);
+ if (hintAnimator != null) {
+ hintAnimator.cancel();
+ hintAnimator = null;
+ }
+ if (current == this) current = null;
+ releaseRingWakeLock();
+ super.onDestroy();
+ }
+
+ private void acquireRingWakeLock() {
+ try {
+ PowerManager pm = getSystemService(PowerManager.class);
+ if (pm == null) return;
+ // Release any wakelock from a prior ring first — onNewIntent (a
+ // superseding ring) re-acquires, and overwriting the field without
+ // releasing would orphan the still-held old lock (L2).
+ releaseRingWakeLock();
+ wakeLock = pm.newWakeLock(
+ PowerManager.PARTIAL_WAKE_LOCK, "chat.vojo.app:IncomingCallWakeLock");
+ wakeLock.setReferenceCounted(false);
+ wakeLock.acquire(RING_SAFETY_TIMEOUT_MS);
+ } catch (Throwable t) {
+ Log.w(TAG, "wakelock acquire failed", t);
+ }
+ }
+
+ private void releaseRingWakeLock() {
+ try {
+ if (wakeLock != null && wakeLock.isHeld()) wakeLock.release();
+ } catch (Throwable t) {
+ Log.w(TAG, "wakelock release failed", t);
+ }
+ wakeLock = null;
+ }
+
+ /**
+ * Dismiss the incoming screen for {@code eventId} if it is the one showing.
+ * Called by VojoFirebaseMessagingService.removeIncomingRing on any terminal
+ * ring transition (answer / decline / expiry / supersede). No-op otherwise.
+ */
+ static void finishFor(String eventId) {
+ IncomingCallActivity a = current;
+ if (a == null || eventId == null) return;
+ if (eventId.equals(a.notifEventId)) {
+ a.runOnUiThread(() -> {
+ if (!a.isFinishing()) a.finish();
+ });
+ }
+ }
+}
diff --git a/android/app/src/main/res/drawable/bg_call_avatar.xml b/android/app/src/main/res/drawable/bg_call_avatar.xml
new file mode 100644
index 00000000..83cd25ff
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_call_avatar.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/bg_call_circle.xml b/android/app/src/main/res/drawable/bg_call_circle.xml
new file mode 100644
index 00000000..cb894e8d
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_call_circle.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/bg_call_glow_answer.xml b/android/app/src/main/res/drawable/bg_call_glow_answer.xml
new file mode 100644
index 00000000..2ec74ec2
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_call_glow_answer.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/bg_call_glow_decline.xml b/android/app/src/main/res/drawable/bg_call_glow_decline.xml
new file mode 100644
index 00000000..a59b488c
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_call_glow_decline.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_call_drag_chevrons.xml b/android/app/src/main/res/drawable/ic_call_drag_chevrons.xml
new file mode 100644
index 00000000..be9a27c7
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_call_drag_chevrons.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_call_handset.xml b/android/app/src/main/res/drawable/ic_call_handset.xml
new file mode 100644
index 00000000..0abd65bf
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_call_handset.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_incoming_call.xml b/android/app/src/main/res/layout/activity_incoming_call.xml
new file mode 100644
index 00000000..02943e40
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_incoming_call.xml
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml
new file mode 100644
index 00000000..0c68472f
--- /dev/null
+++ b/android/app/src/main/res/values-ru/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Ответить
+ Отклонить
+ Входящий звонок
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index 39a2e6e1..fe311bfd 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -4,4 +4,23 @@
native splash, the WebView body, and the in-app AuthSplashScreen all
share a single backdrop and read as one continuous splash. -->
#0d0e11
+
+
+ #0d0e11
+ #e6e6e9
+ #9aa0a6
+ #9580ff
+ #0c0c0e
+ #7dd3a8
+ #0e2e1f
+ #c08e7b
+ #3a1f17
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index ac493cd9..31a74623 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -4,4 +4,8 @@
Vojo
chat.vojo.app
chat.vojo.app
+
+ Answer
+ Decline
+ Incoming call
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index c60a33fd..de323705 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -1,6 +1,16 @@
+
+
+