feat(calls): native full-screen incoming ring UI + drag-to-answer with glow/chevron animation
This commit is contained in:
parent
3d10fc2d3e
commit
99c309d493
12 changed files with 724 additions and 0 deletions
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
6
android/app/src/main/res/drawable/bg_call_avatar.xml
Normal file
6
android/app/src/main/res/drawable/bg_call_avatar.xml
Normal 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>
|
||||
6
android/app/src/main/res/drawable/bg_call_circle.xml
Normal file
6
android/app/src/main/res/drawable/bg_call_circle.xml
Normal 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>
|
||||
12
android/app/src/main/res/drawable/bg_call_glow_answer.xml
Normal file
12
android/app/src/main/res/drawable/bg_call_glow_answer.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/bg_call_glow_decline.xml
Normal file
11
android/app/src/main/res/drawable/bg_call_glow_decline.xml
Normal 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>
|
||||
29
android/app/src/main/res/drawable/ic_call_drag_chevrons.xml
Normal file
29
android/app/src/main/res/drawable/ic_call_drag_chevrons.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/ic_call_handset.xml
Normal file
11
android/app/src/main/res/drawable/ic_call_handset.xml
Normal 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>
|
||||
169
android/app/src/main/res/layout/activity_incoming_call.xml
Normal file
169
android/app/src/main/res/layout/activity_incoming_call.xml
Normal 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>
|
||||
6
android/app/src/main/res/values-ru/strings.xml
Normal file
6
android/app/src/main/res/values-ru/strings.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue