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

View file

@ -15,6 +15,9 @@ import android.view.WindowManager
import android.view.ContextThemeWrapper
import android.widget.TextView
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.os.Handler
import android.os.Looper
import android.widget.ProgressBar
import android.view.animation.OvershootInterpolator
import android.view.animation.DecelerateInterpolator
@ -54,6 +57,7 @@ class PopupWindow(
}
private var currentEffect: PopupEffect = PopupEffect.NONE
private var matrixOverlay: View? = null
private var gamerAnimator: ValueAnimator? = null
private var timer: Timer? = null
init {
@ -88,35 +92,38 @@ class PopupWindow(
}
fun show(uri: Uri, destinationId: Int) {
val service = service?.get() ?: return
if (!remove()) {
service.placeCallUnmodified()
return
}
val svc = service?.get() ?: return
timer?.cancel()
timer = Timer()
timer?.schedule(timerTask {
if (!remove()) {
service.placeCallUnmodified()
return@timerTask
Handler(Looper.getMainLooper()).post {
if (!remove()) {
svc.placeCallUnmodified()
return@post
}
if (audioManager?.mode != AudioManager.MODE_IN_CALL) {
svc.placeCallUnmodified()
return@post
}
try {
call(uri)
} catch (exc: SecurityException) {
svc.placeCallUnmodified()
return@post
}
svc.cancelCall()
}
if (audioManager?.mode != AudioManager.MODE_IN_CALL) {
service.placeCallUnmodified()
return@timerTask
}
try {
call(uri)
} catch (exc: SecurityException) {
service.placeCallUnmodified()
return@timerTask
}
service.cancelCall()
}, prefs.redirectionDelay)
layoutParams.y = prefs.popupPosition
setDescription(destinationId)
startProgressAnimation(prefs.redirectionDelay)
if (!add()) {
Log.w("PopupWindow", "add() failed popup not shown, calling directly.")
timer?.cancel()
service.placeCallUnmodified()
svc.placeCallUnmodified()
}
}
@ -147,10 +154,24 @@ class PopupWindow(
private fun animateAppear() {
view.animate().cancel()
// Always reset all transforms before animation
view.rotationX = 0f
view.alpha = 1f
view.translationX = 0f
view.translationY = 0f
view.scaleX = 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) {
PopupEffect.RANDOM -> PopupEffect.values().filter { it != PopupEffect.RANDOM && it != PopupEffect.NONE }.random()
@ -164,10 +185,15 @@ class PopupWindow(
view.alpha = 0f
view.animate().alpha(1f).setDuration(300).start()
}
PopupEffect.SCALE -> {
view.scaleX = 0f
view.scaleY = 0f
view.animate().scaleX(1f).scaleY(1f).setDuration(300).start()
PopupEffect.SCALE -> { //
view.translationX = view.width.toFloat()
view.alpha = 0f
view.animate()
.translationX(0f)
.alpha(1f)
.setDuration(350)
.setInterpolator(DecelerateInterpolator(2f))
.start()
}
PopupEffect.BOUNCE -> {
view.scaleX = 0.7f
@ -227,18 +253,102 @@ class PopupWindow(
} catch (_: Exception) {}
}.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 -> {}
}
}
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) {
PopupEffect.NONE -> onEnd()
PopupEffect.FADE -> view.animate().alpha(0f).setDuration(200).withEndAction(onEnd).start()
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.NONE -> falls to else
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 -> {
view.animate().alpha(0f).setDuration(150).withEndAction(onEnd).start()
view.animate()
.alpha(0f)
.setDuration(150)
.withEndAction(end)
.start()
matrixOverlay?.let { overlay ->
overlay.animate().cancel()
overlay.animate().alpha(0f).setDuration(150).withEndAction {
@ -249,7 +359,16 @@ class PopupWindow(
}.start()
}
}
else -> onEnd()
PopupEffect.SLIDE_SNAP -> view.animate()
.translationY(200f)
.alpha(0f)
.setDuration(200)
.setInterpolator(DecelerateInterpolator(2f))
.withEndAction(end)
.start()
else -> end.run()
}
}
@ -269,29 +388,36 @@ class PopupWindow(
return true
}
private fun remove(): Boolean {
try {
private fun remove(onRemoved: (() -> Unit)? = null): Boolean {
return try {
animateDisappear {
try {
windowManager?.removeView(view)
matrixOverlay?.let {
try {
windowManager?.removeViewImmediate(it)
} catch (_: Exception) {}
} catch (e: Exception) {
Log.e("PopupWindow", "Failed to remove matrix overlay", e)
}
matrixOverlay = null
}
} catch (_: Exception) {}
} catch (e: Exception) {
Log.e("PopupWindow", "Failed to remove popup view", e)
}
onRemoved?.invoke()
}
} catch (_: Exception) {
return false
true
} catch (e: Exception) {
Log.e("PopupWindow", "Exception during remove()", e)
false
}
return true
}
fun cancel() {
Log.d("PopupWindow", "Cancel called")
timer?.cancel()
remove()
}
}
private fun applyResolvedColors(view: android.view.View) {
val attrSurface = com.google.android.material.R.attr.colorSurface

View file

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

View file

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