diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt b/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt index 62720e5..5eae072 100644 --- a/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt +++ b/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt @@ -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 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 { diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt b/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt index d3059ec..237da52 100644 --- a/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt +++ b/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt @@ -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 diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt b/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt index fdf6c82..cfef7cd 100644 --- a/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt +++ b/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f7f5f18..3351cff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,10 +37,12 @@ None Fade - Scale + Slide Bounce Flop Matrix + Slide Snap + Gamer Mode Random