More animations

This commit is contained in:
partisan 2025-05-25 18:19:02 +02:00
parent b2b839cf72
commit 693607de7c
4 changed files with 204 additions and 44 deletions

View file

@ -4,6 +4,7 @@ import android.Manifest
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.util.Log
import android.provider.ContactsContract import android.provider.ContactsContract
import android.telecom.CallRedirectionService import android.telecom.CallRedirectionService
import android.telecom.PhoneAccountHandle import android.telecom.PhoneAccountHandle
@ -60,11 +61,14 @@ class CallRedirectionService : CallRedirectionService() {
initialPhoneAccount: PhoneAccountHandle, initialPhoneAccount: PhoneAccountHandle,
allowInteractiveResponse: Boolean, allowInteractiveResponse: Boolean,
) { ) {
Log.d("Redirection", "onPlaceCall triggered: uri=$handle, interactive=$allowInteractiveResponse")
val capabilities = connectivityManager val capabilities = connectivityManager
?.getNetworkCapabilities(connectivityManager?.activeNetwork) ?.getNetworkCapabilities(connectivityManager?.activeNetwork)
val isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true val isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
val isCellular = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true val isCellular = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
Log.d("Redirection", "isWifi=$isWifi, isCellular=$isCellular")
val shouldRedirect = when { val shouldRedirect = when {
isWifi && !prefs.redirectOnWifi -> false isWifi && !prefs.redirectOnWifi -> false
@ -72,25 +76,47 @@ class CallRedirectionService : CallRedirectionService() {
else -> true else -> true
} }
if (!prefs.isEnabled || !shouldRedirect || !hasInternet() || !allowInteractiveResponse) { if (!prefs.isEnabled) {
Log.d("Redirection", "Aborting: redirection disabled in prefs")
placeCallUnmodified()
return
}
if (!shouldRedirect) {
Log.d("Redirection", "Aborting: redirection blocked by current network preference")
placeCallUnmodified()
return
}
if (!hasInternet()) {
Log.d("Redirection", "Aborting: no internet connection detected")
placeCallUnmodified()
return
}
if (!allowInteractiveResponse) {
Log.d("Redirection", "Aborting: interactive response not allowed by system")
placeCallUnmodified() placeCallUnmodified()
return return
} }
if (prefs.redirectIfRoaming && !isOutsideHomeCountry()) { if (prefs.redirectIfRoaming && !isOutsideHomeCountry()) {
Log.d("Redirection", "Aborting: redirect only while roaming, but we're inside home country")
placeCallUnmodified() placeCallUnmodified()
return return
} }
val phoneNumber = handle.schemeSpecificPart val phoneNumber = handle.schemeSpecificPart
Log.d("Redirection", "Resolved phone number: $phoneNumber")
// Check if we only redirect international numbers
if (prefs.redirectInternationalOnly && !isInternationalNumber(phoneNumber)) { if (prefs.redirectInternationalOnly && !isInternationalNumber(phoneNumber)) {
Log.d("Redirection", "Aborting: number is not international and pref requires it")
placeCallUnmodified() placeCallUnmodified()
return return
} }
if (prefs.isBlacklistEnabled && !prefs.isContactWhitelisted(phoneNumber)) { if (prefs.isBlacklistEnabled && !prefs.isContactWhitelisted(phoneNumber)) {
Log.d("Redirection", "Aborting: number is not in whitelist while blacklist is enabled")
placeCallUnmodified() placeCallUnmodified()
return return
} }
@ -98,22 +124,28 @@ class CallRedirectionService : CallRedirectionService() {
val records: Array<Record> val records: Array<Record>
try { try {
records = getRecordsFromPhoneNumber(phoneNumber) records = getRecordsFromPhoneNumber(phoneNumber)
Log.d("Redirection", "Found ${records.size} raw records for contact")
} catch (exc: SecurityException) { } catch (exc: SecurityException) {
Log.w("Redirection", "SecurityException during record fetch", exc)
placeCallUnmodified() placeCallUnmodified()
return return
} }
// Filter to enabled services only
val enabledRecords = records val enabledRecords = records
.filter { prefs.isServiceEnabled(it.mimetype) } .filter { prefs.isServiceEnabled(it.mimetype) }
.sortedBy { prefs.getServicePriority(it.mimetype) } .sortedBy { prefs.getServicePriority(it.mimetype) }
Log.d("Redirection", "Filtered to ${enabledRecords.size} enabled records")
val record = enabledRecords.firstOrNull() val record = enabledRecords.firstOrNull()
if (record == null) { if (record == null) {
Log.d("Redirection", "Aborting: no suitable record found for redirection")
placeCallUnmodified() placeCallUnmodified()
return return
} }
Log.d("Redirection", "Redirecting call to: ${record.mimetype}${record.uri}")
if (prefs.popupEnabled) { if (prefs.popupEnabled) {
window.show(record.uri, MIMETYPE_TO_DST_NAME[record.mimetype] ?: return) window.show(record.uri, MIMETYPE_TO_DST_NAME[record.mimetype] ?: return)
} else { } else {

View file

@ -15,6 +15,9 @@ import android.view.WindowManager
import android.view.ContextThemeWrapper import android.view.ContextThemeWrapper
import android.widget.TextView import android.widget.TextView
import android.animation.ObjectAnimator import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.os.Handler
import android.os.Looper
import android.widget.ProgressBar import android.widget.ProgressBar
import android.view.animation.OvershootInterpolator import android.view.animation.OvershootInterpolator
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
@ -54,6 +57,7 @@ class PopupWindow(
} }
private var currentEffect: PopupEffect = PopupEffect.NONE private var currentEffect: PopupEffect = PopupEffect.NONE
private var matrixOverlay: View? = null private var matrixOverlay: View? = null
private var gamerAnimator: ValueAnimator? = null
private var timer: Timer? = null private var timer: Timer? = null
init { init {
@ -88,35 +92,38 @@ class PopupWindow(
} }
fun show(uri: Uri, destinationId: Int) { fun show(uri: Uri, destinationId: Int) {
val service = service?.get() ?: return val svc = service?.get() ?: return
if (!remove()) {
service.placeCallUnmodified()
return
}
timer?.cancel() timer?.cancel()
timer = Timer() timer = Timer()
timer?.schedule(timerTask { timer?.schedule(timerTask {
Handler(Looper.getMainLooper()).post {
if (!remove()) { if (!remove()) {
service.placeCallUnmodified() svc.placeCallUnmodified()
return@timerTask return@post
} }
if (audioManager?.mode != AudioManager.MODE_IN_CALL) { if (audioManager?.mode != AudioManager.MODE_IN_CALL) {
service.placeCallUnmodified() svc.placeCallUnmodified()
return@timerTask return@post
} }
try { try {
call(uri) call(uri)
} catch (exc: SecurityException) { } catch (exc: SecurityException) {
service.placeCallUnmodified() svc.placeCallUnmodified()
return@timerTask return@post
}
svc.cancelCall()
} }
service.cancelCall()
}, prefs.redirectionDelay) }, prefs.redirectionDelay)
layoutParams.y = prefs.popupPosition
setDescription(destinationId) setDescription(destinationId)
startProgressAnimation(prefs.redirectionDelay) startProgressAnimation(prefs.redirectionDelay)
if (!add()) { if (!add()) {
Log.w("PopupWindow", "add() failed popup not shown, calling directly.")
timer?.cancel() timer?.cancel()
service.placeCallUnmodified() svc.placeCallUnmodified()
} }
} }
@ -147,10 +154,24 @@ class PopupWindow(
private fun animateAppear() { private fun animateAppear() {
view.animate().cancel() view.animate().cancel()
// Always reset all transforms before animation
view.rotationX = 0f view.rotationX = 0f
view.alpha = 1f
view.translationX = 0f
view.translationY = 0f
view.scaleX = 1f view.scaleX = 1f
view.scaleY = 1f view.scaleY = 1f
view.alpha = 1f
// Reset gamer effect if it was active before
gamerAnimator?.cancel()
gamerAnimator = null
val card = view as MaterialCardView
themedCtx.obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorOutline)).use { ta ->
val defaultStroke = ta.getColor(0, Color.DKGRAY)
card.strokeColor = defaultStroke
}
val effect = when (prefs.popupEffect) { val effect = when (prefs.popupEffect) {
PopupEffect.RANDOM -> PopupEffect.values().filter { it != PopupEffect.RANDOM && it != PopupEffect.NONE }.random() PopupEffect.RANDOM -> PopupEffect.values().filter { it != PopupEffect.RANDOM && it != PopupEffect.NONE }.random()
@ -164,10 +185,15 @@ class PopupWindow(
view.alpha = 0f view.alpha = 0f
view.animate().alpha(1f).setDuration(300).start() view.animate().alpha(1f).setDuration(300).start()
} }
PopupEffect.SCALE -> { PopupEffect.SCALE -> { //
view.scaleX = 0f view.translationX = view.width.toFloat()
view.scaleY = 0f view.alpha = 0f
view.animate().scaleX(1f).scaleY(1f).setDuration(300).start() view.animate()
.translationX(0f)
.alpha(1f)
.setDuration(350)
.setInterpolator(DecelerateInterpolator(2f))
.start()
} }
PopupEffect.BOUNCE -> { PopupEffect.BOUNCE -> {
view.scaleX = 0.7f view.scaleX = 0.7f
@ -227,18 +253,102 @@ class PopupWindow(
} catch (_: Exception) {} } catch (_: Exception) {}
}.start() }.start()
} }
PopupEffect.SLIDE_SNAP -> {
view.translationY = 200f
view.alpha = 0f
view.animate()
.translationY(0f)
.alpha(1f)
.setDuration(350)
.setInterpolator(OvershootInterpolator(2f))
.start()
}
PopupEffect.GAMER_MODE -> {
val popupCard = view as MaterialCardView
val hsv = floatArrayOf(0f, 1f, 1f)
gamerAnimator?.cancel() // Cancel any existing animator before starting new one
gamerAnimator = ValueAnimator.ofFloat(0f, 360f).apply {
duration = 2000
repeatCount = ValueAnimator.INFINITE
addUpdateListener {
hsv[0] = it.animatedValue as Float
popupCard.strokeColor = Color.HSVToColor(hsv)
}
start()
}
view.alpha = 0f
view.animate()
.alpha(1f)
.setDuration(400)
.start()
}
else -> {} else -> {}
} }
} }
private fun animateDisappear(onEnd: () -> Unit) { private fun animateDisappear(onEnd: () -> Unit) {
// While the reset after animation can be a nice safety net, it's causing visual glitches and is already run at the start of every animation anyway
// val resetAndFinish = {
// view.animate().cancel()
// view.translationX = 0f
// view.translationY = 0f
// view.scaleX = 1f
// view.scaleY = 1f
// view.rotationX = 0f
// view.rotationY = 0f
// view.alpha = 1f
// Log.d("PopupWindow", "Reset and finish after disappear animation")
// onEnd()
// }
val end = Runnable {
Log.d("PopupWindow", "Disappearance animation complete")
view.post { onEnd() } // defer by one frame to ensure alpha=0 is rendered
}
when (currentEffect) { when (currentEffect) {
PopupEffect.NONE -> onEnd()
PopupEffect.FADE -> view.animate().alpha(0f).setDuration(200).withEndAction(onEnd).start() // PopupEffect.NONE -> falls to else
PopupEffect.SCALE, PopupEffect.BOUNCE -> view.animate().scaleX(0f).scaleY(0f).setDuration(200).withEndAction(onEnd).start()
PopupEffect.FLOP -> view.animate().rotationX(90f).alpha(0f).setDuration(200).withEndAction(onEnd).start() PopupEffect.FADE -> view.animate()
.alpha(0f)
.setDuration(200)
.withEndAction(end)
.start()
PopupEffect.SCALE -> view.animate()
.translationX(view.width.toFloat())
.alpha(0f)
.setDuration(200)
.setInterpolator(DecelerateInterpolator(2f))
.withEndAction(end)
.start()
PopupEffect.BOUNCE -> view.animate()
.scaleX(0f)
.scaleY(0f)
.setDuration(200)
.withEndAction(end)
.start()
PopupEffect.FLOP -> view.animate()
.rotationX(90f)
.alpha(0f)
.setDuration(200)
.withEndAction(end)
.start()
PopupEffect.MATRIX -> { PopupEffect.MATRIX -> {
view.animate().alpha(0f).setDuration(150).withEndAction(onEnd).start() view.animate()
.alpha(0f)
.setDuration(150)
.withEndAction(end)
.start()
matrixOverlay?.let { overlay -> matrixOverlay?.let { overlay ->
overlay.animate().cancel() overlay.animate().cancel()
overlay.animate().alpha(0f).setDuration(150).withEndAction { overlay.animate().alpha(0f).setDuration(150).withEndAction {
@ -249,7 +359,16 @@ class PopupWindow(
}.start() }.start()
} }
} }
else -> onEnd()
PopupEffect.SLIDE_SNAP -> view.animate()
.translationY(200f)
.alpha(0f)
.setDuration(200)
.setInterpolator(DecelerateInterpolator(2f))
.withEndAction(end)
.start()
else -> end.run()
} }
} }
@ -269,26 +388,33 @@ class PopupWindow(
return true return true
} }
private fun remove(): Boolean { private fun remove(onRemoved: (() -> Unit)? = null): Boolean {
try { return try {
animateDisappear { animateDisappear {
try { try {
windowManager?.removeView(view) windowManager?.removeView(view)
matrixOverlay?.let { matrixOverlay?.let {
try { try {
windowManager?.removeViewImmediate(it) windowManager?.removeViewImmediate(it)
} catch (_: Exception) {} } catch (e: Exception) {
Log.e("PopupWindow", "Failed to remove matrix overlay", e)
}
matrixOverlay = null matrixOverlay = null
} }
} catch (_: Exception) {} } catch (e: Exception) {
Log.e("PopupWindow", "Failed to remove popup view", e)
} }
} catch (_: Exception) { onRemoved?.invoke()
return false }
true
} catch (e: Exception) {
Log.e("PopupWindow", "Exception during remove()", e)
false
} }
return true
} }
fun cancel() { fun cancel() {
Log.d("PopupWindow", "Cancel called")
timer?.cancel() timer?.cancel()
remove() remove()
} }

View file

@ -42,7 +42,7 @@ class Preferences(private val context: Context) {
hasCallRedirectionRole(context) hasCallRedirectionRole(context)
enum class PopupEffect { enum class PopupEffect {
NONE, FADE, SCALE, BOUNCE, FLOP, MATRIX, RANDOM NONE, FADE, SCALE, BOUNCE, FLOP, MATRIX, SLIDE_SNAP, GAMER_MODE, RANDOM
} }
var popupEffect: PopupEffect var popupEffect: PopupEffect

View file

@ -37,10 +37,12 @@
<string-array name="popup_effects"> <string-array name="popup_effects">
<item>None</item> <item>None</item>
<item>Fade</item> <item>Fade</item>
<item>Scale</item> <item>Slide</item>
<item>Bounce</item> <item>Bounce</item>
<item>Flop</item> <item>Flop</item>
<item>Matrix</item> <item>Matrix</item>
<item>Slide Snap</item>
<item>Gamer Mode</item>
<item>Random</item> <item>Random</item>
</string-array> </string-array>