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 @@ + + +