feat(calls): native full-screen incoming ring UI + drag-to-answer with glow/chevron animation

This commit is contained in:
heaven 2026-06-24 00:43:39 +03:00
parent 3d10fc2d3e
commit 99c309d493
12 changed files with 724 additions and 0 deletions

View file

@ -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 (01 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();
});
}
}
}

View file

@ -0,0 +1,6 @@
<!-- Fallback avatar disc — brand purple (Primary.Main), matching the web
UserAvatar fallback tone. The caller's initial sits on top in OnMain. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/call_avatar" />
</shape>

View file

@ -0,0 +1,6 @@
<!-- White circle; the answer/decline buttons set backgroundTint to colour it
(Success / Critical). Kept white so a single drawable serves both. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#ffffff" />
</shape>

View file

@ -0,0 +1,12 @@
<!-- Soft radial halo behind the Answer disc. Driven in code (alpha + scale by
drag progress) so the button feels like it's charging up as you slide it
past the commit threshold. Success/green tint to match call_answer. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<gradient
android:type="radial"
android:gradientRadius="62dp"
android:startColor="#A67DD3A8"
android:centerColor="#4D7DD3A8"
android:endColor="#007DD3A8" />
</shape>

View file

@ -0,0 +1,11 @@
<!-- Soft radial halo behind the Decline disc (Critical/red tint to match
call_decline). Same code-driven alpha + scale as the answer glow. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<gradient
android:type="radial"
android:gradientRadius="62dp"
android:startColor="#A6C08E7B"
android:centerColor="#4DC08E7B"
android:endColor="#00C08E7B" />
</shape>

View file

@ -0,0 +1,29 @@
<!-- Three stacked upward chevrons — the idle "slide up to answer" affordance
above the Answer disc. Animated in code (rising + fading loop), suppressed
while a drag is in progress. call_answer green. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp"
android:height="40dp"
android:viewportWidth="28"
android:viewportHeight="40">
<path
android:strokeColor="#7DD3A8"
android:strokeWidth="2.6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M6,12 L14,5 L22,12" />
<path
android:strokeColor="#7DD3A8"
android:strokeAlpha="0.7"
android:strokeWidth="2.6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M6,22 L14,15 L22,22" />
<path
android:strokeColor="#7DD3A8"
android:strokeAlpha="0.4"
android:strokeWidth="2.6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M6,32 L14,25 L22,32" />
</vector>

View file

@ -0,0 +1,11 @@
<!-- Material "call" handset. White fill; each button tints it (answer/decline)
via android:tint. The decline button rotates this 135° in the layout. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z" />
</vector>

View file

@ -0,0 +1,169 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Full-screen incoming-call screen, styled to match the Vojo (DAWN) messenger:
flat #0d0e11 backdrop, centred caller identity, a pair of large round
answer/decline controls at the bottom. Native (not WebView) so it paints
instantly over the lockscreen before the iframe could ever load. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/call_bg"
android:gravity="center_horizontal"
android:fitsSystemWindows="true"
android:paddingStart="24dp"
android:paddingEnd="24dp">
<Space
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1.1" />
<!-- Fallback avatar disc with the caller's initial (set in code). -->
<TextView
android:id="@+id/call_avatar"
android:layout_width="112dp"
android:layout_height="112dp"
android:background="@drawable/bg_call_avatar"
android:gravity="center"
android:textColor="@color/call_avatar_on"
android:textSize="46sp"
android:textStyle="bold"
android:includeFontPadding="false" />
<TextView
android:id="@+id/call_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:textColor="@color/call_text"
android:textSize="28sp"
android:textStyle="bold"
android:maxLines="2"
android:ellipsize="end"
android:gravity="center" />
<TextView
android:id="@+id/call_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/call_text_muted"
android:textSize="16sp"
android:gravity="center" />
<Space
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="2" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="56dp"
android:weightSum="2">
<!-- Decline -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<!-- Empty hint slot — keeps the decline disc vertically aligned with
the answer disc, which carries the drag-hint chevrons. -->
<ImageView
android:id="@+id/decline_hint"
android:layout_width="28dp"
android:layout_height="40dp"
android:layout_marginBottom="4dp"
android:visibility="invisible" />
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<View
android:id="@+id/decline_glow"
android:layout_width="124dp"
android:layout_height="124dp"
android:layout_gravity="center"
android:alpha="0"
android:background="@drawable/bg_call_glow_decline" />
<ImageButton
android:id="@+id/call_decline"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_gravity="center"
android:background="@drawable/bg_call_circle"
android:backgroundTint="@color/call_decline"
android:src="@drawable/ic_call_handset"
android:tint="@color/call_decline_on"
android:scaleType="center"
android:rotation="135"
android:contentDescription="@string/call_decline" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:rotation="0"
android:text="@string/call_decline"
android:textColor="@color/call_text_muted"
android:textSize="14sp" />
</LinearLayout>
<!-- Answer -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<ImageView
android:id="@+id/answer_hint"
android:layout_width="28dp"
android:layout_height="40dp"
android:layout_marginBottom="4dp"
android:src="@drawable/ic_call_drag_chevrons"
android:contentDescription="@null" />
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<View
android:id="@+id/answer_glow"
android:layout_width="124dp"
android:layout_height="124dp"
android:layout_gravity="center"
android:alpha="0"
android:background="@drawable/bg_call_glow_answer" />
<ImageButton
android:id="@+id/call_answer"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_gravity="center"
android:background="@drawable/bg_call_circle"
android:backgroundTint="@color/call_answer"
android:src="@drawable/ic_call_handset"
android:tint="@color/call_answer_on"
android:scaleType="center"
android:contentDescription="@string/call_answer" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/call_answer"
android:textColor="@color/call_text_muted"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="call_answer">Ответить</string>
<string name="call_decline">Отклонить</string>
<string name="call_incoming">Входящий звонок</string>
</resources>

View file

@ -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. -->
<color name="splash_bg">#0d0e11</color>
<!-- IncomingCallActivity palette — bound 1:1 to the web DAWN tokens
(src/colors.css.ts) so the native full-screen ring screen reads as the
same messenger, not a stock Android dialog.
call_bg = Background.Container #0d0e11
call_text = *.OnContainer #e6e6e9
call_text_muted = muted neutral
call_avatar = Primary.Main (purple) #9580ff / OnMain #0c0c0e
call_answer = Success.Main #7dd3a8 / OnMain #0e2e1f
call_decline = Critical.Main #c08e7b / OnMain #3a1f17 -->
<color name="call_bg">#0d0e11</color>
<color name="call_text">#e6e6e9</color>
<color name="call_text_muted">#9aa0a6</color>
<color name="call_avatar">#9580ff</color>
<color name="call_avatar_on">#0c0c0e</color>
<color name="call_answer">#7dd3a8</color>
<color name="call_answer_on">#0e2e1f</color>
<color name="call_decline">#c08e7b</color>
<color name="call_decline_on">#3a1f17</color>
</resources>

View file

@ -4,4 +4,8 @@
<string name="title_activity_main">Vojo</string>
<string name="package_name">chat.vojo.app</string>
<string name="custom_url_scheme">chat.vojo.app</string>
<!-- IncomingCallActivity labels (ru override in values-ru/strings.xml). -->
<string name="call_answer">Answer</string>
<string name="call_decline">Decline</string>
<string name="call_incoming">Incoming call</string>
</resources>

View file

@ -1,6 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Full-screen incoming-call screen (IncomingCallActivity). Dark, no
action bar, DAWN backdrop, transparent system bars so the screen runs
edge-to-edge over the lockscreen like the rest of the messenger. -->
<style name="IncomingCallTheme" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/call_bg</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowNoTitle">true</item>
</style>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>