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: