From 355ec2df48d2680fe0cca93bf252958f050469c9 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 19 Oct 2026 12:49:09 +0300 Subject: [PATCH] feat(video_calls): added phone waking up after it gets call push --- android/app/src/main/AndroidManifest.xml | 17 +- .../EatvioCallkitIncomingBridgeActivity.kt | 318 +++++++++++++++++- .../com/eatvioapp/app/VideoCallPushBridge.kt | 50 +++ pubspec.yaml | 2 +- 4 files changed, 373 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3e73e6bf..c2b6f6e2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -87,8 +87,11 @@ android:name="flutterEmbedding" android:value="2" /> - + @@ -97,20 +100,26 @@ android:name=".EatvioCallkitIncomingBridgeActivity" android:exported="false" android:theme="@android:style/Theme.Translucent.NoTitleBar" + android:taskAffinity="" + android:allowTaskReparenting="false" android:excludeFromRecents="true" android:noHistory="true" + android:showWhenLocked="true" android:turnScreenOn="true" - android:launchMode="singleTop" + android:launchMode="singleInstance" android:configChanges="orientation" /> diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioCallkitIncomingBridgeActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioCallkitIncomingBridgeActivity.kt index 965e2af0..503e05e8 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioCallkitIncomingBridgeActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioCallkitIncomingBridgeActivity.kt @@ -1,12 +1,71 @@ package com.eatvioapp.app import android.app.Activity +import android.app.KeyguardManager +import android.content.Context +import android.content.Intent +import android.content.pm.ActivityInfo +import android.graphics.Color +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.view.View +import android.view.WindowManager +import android.view.ViewGroup.MarginLayoutParams +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.hiennv.flutter_callkit_incoming.AppUtils import com.hiennv.flutter_callkit_incoming.CallkitConstants +import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager +import com.hiennv.flutter_callkit_incoming.ImageLoaderProvider +import com.hiennv.flutter_callkit_incoming.Utils +import com.hiennv.flutter_callkit_incoming.widgets.RippleRelativeLayout +import java.lang.ref.WeakReference +import kotlin.math.abs +import com.hiennv.flutter_callkit_incoming.R as CallkitR -/// Redirects notification body / full-screen intent taps to the same accept path as -/// the Answer button, instead of showing the native Callkit incoming UI. +/// Target of the incoming notification's content intent and full-screen intent. +/// +/// Unlocked, lit screen: the user tapped the notification body, route it to the same +/// accept flow as the Answer button. +/// +/// Locked or dark screen: Android fired the full-screen intent on its own, so show a +/// ringing screen that wakes the device and lets the user answer or decline. class EatvioCallkitIncomingBridgeActivity : Activity() { + companion object { + private const val TAG = "EatvioIncomingBridge" + private const val WAKE_LOCK_TAG = "eatvio:incoming_call" + private const val DEFAULT_RING_DURATION_MS = 45_000L + private const val MIN_RING_SCREEN_MS = 1_000L + + private val mainHandler = Handler(Looper.getMainLooper()) + private var ringingScreen: WeakReference? = null + + /** + * Closes the ringing screen once the call stopped ringing: answered or declined + * from the notification, timed out, or ended by the caller. + */ + fun dismissRingingScreen() { + mainHandler.post { + ringingScreen?.get()?.closeRingingScreen() + ringingScreen = null + } + } + + fun shouldShowRingingScreen(keyguardLocked: Boolean, screenInteractive: Boolean): Boolean { + return keyguardLocked || !screenInteractive + } + } + + private var callData: Bundle? = null + private var wakeLock: PowerManager.WakeLock? = null + private var ringTimeout: Runnable? = null + private var actionTaken = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -15,15 +74,256 @@ class EatvioCallkitIncomingBridgeActivity : Activity() { finish() return } + callData = data - CallKitIntentBridge.dispatchCallKitAction( - this, - CallkitConstants.ACTION_CALL_ACCEPT, - data, + if (!launchedOnLockedOrDarkScreen()) { + Log.i(TAG, "tap on notification body: accept") + CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data) + finish() + @Suppress("DEPRECATION") + overridePendingTransition(0, 0) + return + } + + Log.i(TAG, "full-screen intent on locked/dark screen: show ringing screen") + showRingingScreen(data) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA) ?: return + if (ringingScreen?.get() !== this) return + callData = data + bindCallData(data) + scheduleRingTimeout(data) + } + + override fun onDestroy() { + cancelRingTimeout() + releaseWakeLock() + if (ringingScreen?.get() === this) { + ringingScreen = null + } + super.onDestroy() + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + // The ringing screen is dismissed by Answer / Decline / timeout only. + } + + private fun launchedOnLockedOrDarkScreen(): Boolean { + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + return shouldShowRingingScreen( + keyguardLocked = keyguardManager.isKeyguardLocked, + screenInteractive = powerManager.isInteractive, + ) + } + + private fun showRingingScreen(data: Bundle) { + ringingScreen = WeakReference(this) + + setTheme(CallkitR.style.CallkitIncomingTheme) + requestedOrientation = if (Utils.isTablet(this)) { + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } else { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setTurnScreenOn(true) + setShowWhenLocked(true) + } else { + @Suppress("DEPRECATION") + window.addFlags( + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED, + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + window.statusBarColor = Color.TRANSPARENT + window.navigationBarColor = Color.TRANSPARENT + } + + acquireWakeLock(ringDuration(data)) + VideoCallPushBridge.keepRingingOnFullScreen() + + setContentView(CallkitR.layout.activity_callkit_incoming) + bindCallData(data) + scheduleRingTimeout(data) + } + + private fun bindCallData(data: Bundle) { + val background: ImageView = findViewById(CallkitR.id.ivBackground) + val ripple: RippleRelativeLayout = findViewById(CallkitR.id.llBackgroundAnimation) + val callerName: TextView = findViewById(CallkitR.id.tvNameCaller) + val callerNumber: TextView = findViewById(CallkitR.id.tvNumber) + val logo: ImageView = findViewById(CallkitR.id.ivLogo) + val avatar: ImageView = findViewById(CallkitR.id.ivAvatar) + val actions: LinearLayout = findViewById(CallkitR.id.llAction) + val acceptButton: ImageView = findViewById(CallkitR.id.ivAcceptCall) + val acceptLabel: TextView = findViewById(CallkitR.id.tvAccept) + val declineButton: ImageView = findViewById(CallkitR.id.ivDeclineCall) + val declineLabel: TextView = findViewById(CallkitR.id.tvDecline) + + ripple.layoutParams.height = Utils.getScreenWidth() + Utils.getStatusBarHeight(this) + ripple.startRippleAnimation() + (actions.layoutParams as? MarginLayoutParams)?.let { params -> + params.setMargins(0, 0, 0, Utils.getNavigationBarHeight(this)) + actions.layoutParams = params + } + + callerName.text = data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, "") + callerNumber.visibility = View.INVISIBLE + logo.visibility = View.INVISIBLE + + val textColor = parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR), Color.WHITE) + callerName.setTextColor(textColor) + acceptLabel.setTextColor(textColor) + declineLabel.setTextColor(textColor) + background.setBackgroundColor( + parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR), Color.parseColor("#0955fa")), ) - finish() - @Suppress("DEPRECATION") - overridePendingTransition(0, 0) + val avatarUrl = data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, "") + if (!avatarUrl.isNullOrEmpty()) { + avatar.visibility = View.VISIBLE + @Suppress("DEPRECATION", "UNCHECKED_CAST") + val headers = data.getSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS) as? HashMap + try { + ImageLoaderProvider.loadImage(this, avatarUrl, headers, CallkitR.drawable.ic_default_avatar, avatar) + } catch (error: Exception) { + Log.w(TAG, "failed to load caller avatar", error) + } + } + + if (data.getInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) > 0) { + acceptButton.setImageResource(CallkitR.drawable.ic_video) + } + acceptButton.background = AppUtils.createCircleDrawable( + parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_ACCEPT_COLOR), Color.parseColor("#4CAF50")), + ) + declineButton.background = AppUtils.createCircleDrawable( + parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_DECLINE_COLOR), Color.parseColor("#F44336")), + ) + + val acceptText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "") + acceptLabel.text = if (acceptText.isNullOrEmpty()) getString(CallkitR.string.text_accept) else acceptText + val declineText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "") + declineLabel.text = if (declineText.isNullOrEmpty()) getString(CallkitR.string.text_decline) else declineText + + acceptButton.setOnClickListener { onAcceptClick() } + declineButton.setOnClickListener { onDeclineClick() } + } + + private fun onAcceptClick() { + val data = callData ?: return + if (actionTaken) return + actionTaken = true + cancelRingTimeout() + Log.i(TAG, "ringing screen: accept") + + // Same order as the plugin's own incoming activity: dispatch the accept, then ask + // the keyguard to unlock so MainActivity (which cannot show over the keyguard) + // becomes visible as soon as the user unlocks. + CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + if (keyguardManager.isKeyguardLocked) { + keyguardManager.requestDismissKeyguard(this, null) + } + } + finishRingingTask() + } + + private fun onDeclineClick() { + val data = callData ?: return + if (actionTaken) return + actionTaken = true + cancelRingTimeout() + Log.i(TAG, "ringing screen: decline") + + CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_DECLINE, data) + finishRingingTask() + } + + private fun closeRingingScreen() { + if (isFinishing) return + cancelRingTimeout() + finishRingingTask() + } + + private fun finishRingingTask() { + if (isFinishing) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + finishAndRemoveTask() + } else { + finish() + } + } + + private fun ringDuration(data: Bundle): Long { + val duration = data.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, DEFAULT_RING_DURATION_MS) + return if (duration > 0L) duration else DEFAULT_RING_DURATION_MS + } + + /** + * The notification was posted before this screen appeared, so ring only for the + * remaining part of the call duration. + */ + private fun scheduleRingTimeout(data: Bundle) { + cancelRingTimeout() + val now = System.currentTimeMillis() + val startedAt = data.getLong(CallkitNotificationManager.EXTRA_TIME_START_CALL, now) + val remaining = (ringDuration(data) - abs(now - startedAt)).coerceAtLeast(MIN_RING_SCREEN_MS) + val timeout = Runnable { + Log.i(TAG, "ringing screen: timed out") + closeRingingScreen() + } + ringTimeout = timeout + mainHandler.postDelayed(timeout, remaining) + } + + private fun cancelRingTimeout() { + ringTimeout?.let { mainHandler.removeCallbacks(it) } + ringTimeout = null + } + + @Suppress("DEPRECATION") + private fun acquireWakeLock(durationMs: Long) { + try { + val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager + val lock = powerManager.newWakeLock( + PowerManager.SCREEN_BRIGHT_WAKE_LOCK or + PowerManager.ACQUIRE_CAUSES_WAKEUP or + PowerManager.ON_AFTER_RELEASE, + WAKE_LOCK_TAG, + ) + lock.acquire(durationMs) + wakeLock = lock + } catch (error: Exception) { + Log.w(TAG, "failed to acquire wake lock", error) + } + } + + private fun releaseWakeLock() { + val lock = wakeLock ?: return + wakeLock = null + try { + if (lock.isHeld) lock.release() + } catch (error: Exception) { + Log.w(TAG, "failed to release wake lock", error) + } + } + + private fun parseColor(value: String?, fallback: Int): Int { + if (value.isNullOrBlank()) return fallback + return try { + Color.parseColor(value) + } catch (error: IllegalArgumentException) { + fallback + } } } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index f4aa5333..50afdac6 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.PowerManager import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -24,6 +25,7 @@ object VideoCallPushBridge { private const val TAG = "VideoCallPushBridge" private const val ANDROID_ACCENT_COLOR = "#2787F5" private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L + private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring" private val mainHandler = Handler(Looper.getMainLooper()) private val playbackLock = Any() @@ -34,6 +36,7 @@ object VideoCallPushBridge { private var activeCallBundle: Bundle? = null private var storedAppContext: Context? = null private var ringtoneTimeout: Runnable? = null + private var ringWakeLock: PowerManager.WakeLock? = null private var callbackRegistered = false /** @@ -167,6 +170,16 @@ object VideoCallPushBridge { } } + /** + * The plugin's sound player stops on ACTION_SCREEN_OFF. The ringing screen turns + * the display on itself, so keep ringing through that transition. + */ + fun keepRingingOnFullScreen() { + synchronized(playbackLock) { + activeSoundManager?.keepRingingOnFullScreen() + } + } + fun dismissAllIncomingCalls(context: Context) { val appContext = context.applicationContext val calls = getDataActiveCalls(appContext) @@ -239,6 +252,7 @@ object VideoCallPushBridge { } val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L) + wakeScreenLocked(appContext, if (durationMs > 0L) durationMs else 45_000L) if (durationMs > 0L) { val timeout = Runnable { synchronized(playbackLock) { @@ -252,8 +266,43 @@ object VideoCallPushBridge { } } + /** + * Turns the screen on while the call rings. The full-screen intent of the + * notification does the same only when the system grants it; the wake lock + * works without any grant, so the lock screen with the call card lights up. + */ + @Suppress("DEPRECATION") + private fun wakeScreenLocked(appContext: Context, durationMs: Long) { + releaseScreenWakeLockLocked() + try { + val powerManager = appContext.getSystemService(Context.POWER_SERVICE) as PowerManager + val lock = powerManager.newWakeLock( + PowerManager.SCREEN_BRIGHT_WAKE_LOCK or + PowerManager.ACQUIRE_CAUSES_WAKEUP or + PowerManager.ON_AFTER_RELEASE, + WAKE_LOCK_TAG, + ) + lock.acquire(durationMs) + ringWakeLock = lock + Log.i(TAG, "wakeScreen: screen on for ${durationMs}ms, interactive=${powerManager.isInteractive}") + } catch (error: Exception) { + Log.w(TAG, "wakeScreen: failed to acquire wake lock", error) + } + } + + private fun releaseScreenWakeLockLocked() { + val lock = ringWakeLock ?: return + ringWakeLock = null + try { + if (lock.isHeld) lock.release() + } catch (error: Exception) { + Log.w(TAG, "wakeScreen: failed to release wake lock", error) + } + } + private fun stopPlaybackLocked(appContext: Context) { cancelRingtoneTimeoutLocked() + releaseScreenWakeLockLocked() val notificationManager = activeNotificationManager val bundle = activeCallBundle stopIncomingRingtone() @@ -267,6 +316,7 @@ object VideoCallPushBridge { activeSoundManager = null activeNotificationManager = null activeCallBundle = null + EatvioCallkitIncomingBridgeActivity.dismissRingingScreen() } private fun cancelRingtoneTimeoutLocked() { diff --git a/pubspec.yaml b/pubspec.yaml index 088eb5eb..ddb3a73c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.55+144 +version: 1.3.56+145 publish_to: 'none' environment: