diff --git a/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java b/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java
index 76e45800..137f2d24 100644
--- a/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java
+++ b/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java
@@ -1,10 +1,12 @@
package chat.vojo.app;
+import android.animation.ArgbEvaluator;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.app.KeyguardManager;
import android.content.Context;
import android.content.Intent;
+import android.content.res.ColorStateList;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
@@ -17,8 +19,9 @@ import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.WindowManager;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import android.view.animation.DecelerateInterpolator;
import android.view.animation.LinearInterpolator;
-import android.view.animation.OvershootInterpolator;
import android.widget.ImageButton;
import android.widget.TextView;
@@ -71,6 +74,18 @@ public class IncomingCallActivity extends Activity {
private ValueAnimator hintAnimator;
private volatile boolean dragHintSuppressed;
+ // Slow "breathing" pulse behind the caller avatar (the native mirror of the
+ // web css.CallerPulse) so the lockscreen ring's avatar isn't dead-static.
+ private ValueAnimator haloAnimator;
+
+ // Drag-to-answer: the answer disc ripens Success-green → go-green as the slide
+ // nears commit (bound to the colors.xml call_answer / call_answer_confirm
+ // tokens), and a 3-rung ascending haptic ladder fires along the way.
+ private static final int ANSWER_TINT_BASE = 0xFF7DD3A8;
+ private static final int ANSWER_TINT_CONFIRM = 0xFF34D399;
+ private final ArgbEvaluator argbEval = new ArgbEvaluator();
+ private int lastHapticRung = -1;
+
@Override
protected void attachBaseContext(Context base) {
// Render the ring screen in the in-app language (matches CallStyle / strip /
@@ -108,16 +123,18 @@ public class IncomingCallActivity extends Activity {
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)));
+ // Slide-up-to-answer: a plain tap fires immediately; the moment a drag
+ // begins, the disc tracks the finger UP its rail (the direction the chevron
+ // hint points), ripens toward go-green, drives a radial glow + an ascending
+ // haptic ladder, and commits once dragged past the threshold (or flicked
+ // up). Releasing short springs back, cancelling. DECLINE is tap-only — only
+ // Answer carries the slide, so a stray drag can never reject a call.
+ // performClick() routes the commit through the click listener above, so the
+ // keyboard/TalkBack path stays live.
+ answerBtn.setOnTouchListener(
+ dragToCommit(answerBtn, findViewById(R.id.answer_glow), declineBtn));
startDragHint(findViewById(R.id.answer_hint));
+ startAvatarHalo(findViewById(R.id.call_avatar_halo));
acquireRingWakeLock();
current = this;
@@ -151,7 +168,12 @@ public class IncomingCallActivity extends Activity {
return false;
}
String callerName = intent.getStringExtra("caller_name");
- if (callerName == null || callerName.trim().isEmpty()) callerName = "Vojo";
+ if (callerName == null || callerName.trim().isEmpty()) {
+ // No caller name in the ring payload — name the state, not the app
+ // ("Vojo" read as if the app itself were calling). Matches the web
+ // strip's Call.unknown_caller fallback.
+ callerName = getString(R.string.call_unknown_caller);
+ }
((TextView) findViewById(R.id.call_name)).setText(callerName);
((TextView) findViewById(R.id.call_subtitle)).setText(R.string.call_incoming);
@@ -211,30 +233,27 @@ public class IncomingCallActivity extends Activity {
return km != null && km.isKeyguardLocked();
}
- // ── Drag-to-activate gesture (slide a ring button to commit) ──
+ // ── Slide-up-to-answer gesture (drag the answer disc up 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) {
+ // Tap (no drag past touch-slop) → performClick() on UP. Drag → the disc tracks
+ // the finger UP its rail (the axis the chevron hint points; sideways/downward
+ // drags never commit, so the affordance can't lie), and CONTINUOUSLY along the
+ // slide the disc scales + tilts, ripens Success-green → go-green, drives the
+ // radial [glow], recedes the [decline] disc, and steps a 3-rung haptic ladder.
+ // Dragging (or flicking) past the commit distance performs the click; releasing
+ // short springs everything back. Progress visuals are set directly (zero
+ // latency) during the drag; animate() is used only for the press-lift and the
+ // spring-back. DECLINE is tap-only — only the answer disc gets this listener.
+ private View.OnTouchListener dragToCommit(final View disc, final View glow, final View decline) {
final int touchSlop = ViewConfiguration.get(this).getScaledTouchSlop();
+ // Deliberate upward travel — NOT the old ~40dp (half a disc radius), which
+ // made "slide to answer" indistinguishable from a twitch. dp(88) ≈ a disc
+ // diameter of climb. needsDevice: tune one-handed reach on a tall phone.
+ final float commitDistance = Math.max(touchSlop * 4f, dp(88f));
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) {
@@ -243,54 +262,46 @@ public class IncomingCallActivity extends Activity {
downX = e.getRawX();
downY = e.getRawY();
dragging = false;
- pastThreshold = false;
- v.animate().scaleX(1.06f).scaleY(1.06f).setDuration(120).start();
+ lastHapticRung = -1;
+ disc.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) {
+ if (!dragging && Math.hypot(dx, dy) > touchSlop) {
dragging = true;
dragHintSuppressed = true;
+ // Cancel the in-flight press-lift so it stops fighting the
+ // direct scale-sets below (the old code let both run for
+ // ~120ms → a scale jitter at the start of every drag).
+ disc.animate().cancel();
}
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);
- }
- }
+ float up = -dy; // upward = positive progress
+ float p = (float) Math.min(1.0, Math.max(0.0, up / commitDistance));
+ // Track the finger UP its rail: a little lateral follow,
+ // but the disc never travels downward (down = no commit).
+ disc.setTranslationX(0.15f * dx);
+ disc.setTranslationY(Math.min(0f, dy));
+ applyProgressVisuals(disc, glow, decline, p);
+ hapticLadder(disc, p);
}
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);
+ float up = -(e.getRawY() - downY);
+ boolean commit = !dragging || up >= commitDistance;
if (commit) {
- // Tap or a committed drag → fire. The activity finishes
+ // Tap or a committed slide → fire. The activity finishes
// immediately, so no need to settle the visuals.
- v.performClick();
+ disc.performClick();
} else {
- springBackDragButton(v, glow);
+ springBackDragButton(disc, glow, decline, up / commitDistance);
}
return true;
}
case MotionEvent.ACTION_CANCEL:
- springBackDragButton(v, glow);
+ springBackDragButton(disc, glow, decline, 0f);
return true;
default:
return false;
@@ -299,35 +310,105 @@ public class IncomingCallActivity extends Activity {
};
}
- // 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()
+ // Maps drag progress p (0→1) onto every live visual, so the disc, glow, tint,
+ // tilt and the receding decline disc move as one body. Driven directly from the
+ // finger during a drag and from the spring-back on release.
+ private void applyProgressVisuals(View disc, View glow, View decline, float p) {
+ disc.setScaleX(1.06f + 0.16f * p);
+ disc.setScaleY(1.06f + 0.16f * p);
+ disc.setRotation(-6f * p); // subtle "lifting the handset" tilt
+ // Ripen toward go-green only over the last 40% so a short release leaves the
+ // disc at its base tint (no colour snap on spring-back).
+ float cp = Math.min(1f, Math.max(0f, (p - 0.6f) / 0.4f));
+ disc.setBackgroundTintList(ColorStateList.valueOf(
+ (Integer) argbEval.evaluate(cp, ANSWER_TINT_BASE, ANSWER_TINT_CONFIRM)));
+ if (glow != null) {
+ glow.setAlpha(0.18f + 0.82f * p);
+ float gs = 0.58f + 0.42f * p; // peak 1.0 (the 124dp glow already covers the disc)
+ glow.setScaleX(gs);
+ glow.setScaleY(gs);
+ }
+ if (decline != null) {
+ // The screen "takes a side": the decline disc recedes as answer climbs.
+ decline.setAlpha(1f - 0.45f * p);
+ decline.setScaleX(1f - 0.12f * p);
+ decline.setScaleY(1f - 0.12f * p);
+ }
+ }
+
+ // Three ascending rungs along the slide (engage / near / commit), each once per
+ // drag. performHapticFeedback honours the system haptic toggle (no Vibrator
+ // permission / no manual settings check needed). needsDevice: the felt crescendo
+ // wants a richer VibrationEffect.Composition ladder (see drag spec) on real HW.
+ private void hapticLadder(View v, float p) {
+ int rung = p >= 1f ? 3 : p >= 0.85f ? 2 : p >= 0.45f ? 1 : 0;
+ if (rung <= lastHapticRung) return;
+ lastHapticRung = rung;
+ if (rung == 0) return;
+ v.performHapticFeedback(rung >= 3
+ ? HapticFeedbackConstants.LONG_PRESS : HapticFeedbackConstants.CLOCK_TICK);
+ }
+
+ // Spring the disc back to rest and fade the glow out (slightly trailing, so the
+ // halo is the last energy to leave). A gentle, distance-aware decelerate settle
+ // replaces the old OvershootInterpolator(2.2f) "boing". Under reduced motion the
+ // framework zeroes these durations, so they snap to rest. Re-enables the idle
+ // hint only once the disc has actually landed (withEndAction).
+ private void springBackDragButton(View disc, View glow, View decline, float pRelease) {
+ float p = Math.min(1f, Math.max(0f, pRelease));
+ long dur = (long) (200 + 140 * p);
+ disc.animate()
.translationX(0)
.translationY(0)
.scaleX(1f)
.scaleY(1f)
- .setInterpolator(new OvershootInterpolator(2.2f))
- .setDuration(340)
+ .rotation(0f)
+ .setInterpolator(new DecelerateInterpolator(1.6f))
+ .setDuration(dur)
+ .withEndAction(() -> dragHintSuppressed = false)
.start();
- if (glow != null) {
- glow.animate().alpha(0f).scaleX(0.58f).scaleY(0.58f).setDuration(220).start();
+ // Animate the tint back so a top-zone release doesn't snap green→green.
+ float cp = Math.min(1f, Math.max(0f, (p - 0.6f) / 0.4f));
+ if (cp > 0f) {
+ Integer fromColor = (Integer) argbEval.evaluate(cp, ANSWER_TINT_BASE, ANSWER_TINT_CONFIRM);
+ ValueAnimator tint = ValueAnimator.ofObject(argbEval, fromColor, ANSWER_TINT_BASE);
+ tint.setDuration(dur);
+ tint.addUpdateListener(a -> disc.setBackgroundTintList(
+ ColorStateList.valueOf((Integer) a.getAnimatedValue())));
+ tint.start();
+ } else {
+ disc.setBackgroundTintList(ColorStateList.valueOf(ANSWER_TINT_BASE));
+ }
+ if (glow != null) {
+ glow.animate().alpha(0f).scaleX(0.58f).scaleY(0.58f)
+ .setInterpolator(new DecelerateInterpolator())
+ .setDuration(dur + 60)
+ .start();
+ }
+ if (decline != null) {
+ decline.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(dur).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.
+ // 1.5s eased cycle. Suppressed (held invisible) while a drag is active so the
+ // hint never fights the live gesture. Reduced motion → no loop (held static),
+ // since an infinite looping affordance is exactly what motion-sensitive users
+ // disable.
private void startDragHint(final View hint) {
if (hint == null) return;
+ if (!ValueAnimator.areAnimatorsEnabled()) {
+ hint.setTranslationY(0f);
+ hint.setAlpha(0.85f);
+ 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.setInterpolator(new AccelerateDecelerateInterpolator());
a.addUpdateListener(an -> {
if (dragHintSuppressed) {
hint.setAlpha(0f);
@@ -349,6 +430,29 @@ public class IncomingCallActivity extends Activity {
hintAnimator = a;
}
+ // Slow "breathing" halo behind the caller avatar — the native mirror of the
+ // web css.CallerPulse so the most-stared-at call surface isn't a dead static
+ // disc. The halo scales out while fading (a ring expanding + dissolving), green
+ // (#7dd3a8) = "answerable call ringing". Reduced motion → no pulse (held clear).
+ private void startAvatarHalo(final View halo) {
+ if (halo == null) return;
+ halo.setAlpha(0f);
+ if (!ValueAnimator.areAnimatorsEnabled()) return;
+ ValueAnimator a = ValueAnimator.ofFloat(0f, 1f);
+ a.setDuration(1800);
+ a.setRepeatCount(ValueAnimator.INFINITE);
+ a.setInterpolator(new LinearInterpolator());
+ a.addUpdateListener(an -> {
+ float f = (float) an.getAnimatedValue();
+ float s = 0.9f + 0.25f * f;
+ halo.setScaleX(s);
+ halo.setScaleY(s);
+ halo.setAlpha(0.5f * (1f - f));
+ });
+ a.start();
+ haloAnimator = a;
+ }
+
private float dp(float value) {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, value, getResources().getDisplayMetrics());
@@ -393,6 +497,10 @@ public class IncomingCallActivity extends Activity {
hintAnimator.cancel();
hintAnimator = null;
}
+ if (haloAnimator != null) {
+ haloAnimator.cancel();
+ haloAnimator = null;
+ }
if (current == this) current = null;
releaseRingWakeLock();
super.onDestroy();
diff --git a/android/app/src/main/res/drawable/bg_call_avatar_halo.xml b/android/app/src/main/res/drawable/bg_call_avatar_halo.xml
new file mode 100644
index 00000000..c4b6ad37
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_call_avatar_halo.xml
@@ -0,0 +1,14 @@
+
+
+
+
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
deleted file mode 100644
index a59b488c..00000000
--- a/android/app/src/main/res/drawable/bg_call_glow_decline.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
diff --git a/android/app/src/main/res/drawable/ic_call_handset.xml b/android/app/src/main/res/drawable/ic_call_handset.xml
index 0abd65bf..271ab81e 100644
--- a/android/app/src/main/res/drawable/ic_call_handset.xml
+++ b/android/app/src/main/res/drawable/ic_call_handset.xml
@@ -1,8 +1,8 @@
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
Ответить
Отклонить
Входящий звонок
+ Неизвестный абонент
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index fe311bfd..be48fa14 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -21,6 +21,10 @@
#0c0c0e
#7dd3a8
#0e2e1f
+
+ #34d399
#c08e7b
#3a1f17
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 31a74623..973066f7 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -8,4 +8,5 @@
Answer
Decline
Incoming call
+ Unknown caller