forked from eatvio/eatvio_app
feat(video_calls): added phone waking up after it gets call push
This commit is contained in:
@@ -87,8 +87,11 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
|
||||
Replace it with a bridge that routes to the same accept flow as Answer. -->
|
||||
<!-- The incoming notification's content intent and full-screen intent both open
|
||||
CallkitIncomingActivity. Replace it with a bridge: a tap on an unlocked phone
|
||||
routes to the same accept flow as Answer; on a locked/dark screen it shows a
|
||||
ringing screen that wakes the device. Own task affinity keeps the plugin's
|
||||
CLEAR_TASK flag from killing MainActivity. -->
|
||||
<activity
|
||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||
tools:node="remove" />
|
||||
@@ -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" />
|
||||
|
||||
<activity-alias
|
||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
||||
android:exported="true"
|
||||
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"
|
||||
android:permission="${applicationId}.PERMISSION_CALL">
|
||||
<intent-filter>
|
||||
|
||||
@@ -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<EatvioCallkitIncomingBridgeActivity>? = 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<String, Any?>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user