Coverage Summary for Class: PushNotificationManager (cloud.mindbox.mobile_sdk.pushes)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| PushNotificationManager |
12%
(3/25)
|
0%
(0/66)
|
1%
(3/315)
|
3.6%
(37/1022)
|
| PushNotificationManager$createNotificationChannel$1 |
0%
(0/1)
|
|
0%
(0/5)
|
0%
(0/18)
|
| PushNotificationManager$createPendingIntent$1 |
100%
(1/1)
|
75%
(3/4)
|
100%
(14/14)
|
97.7%
(84/86)
|
| PushNotificationManager$handleRemoteMessage$2 |
0%
(0/1)
|
|
0%
(0/18)
|
0%
(0/50)
|
| PushNotificationManager$isNotificationActive$1 |
0%
(0/1)
|
0%
(0/8)
|
0%
(0/1)
|
0%
(0/46)
|
| PushNotificationManager$isNotificationsEnabled$1 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(5/5)
|
| PushNotificationManager$setIconColor$1$1 |
0%
(0/1)
|
0%
(0/4)
|
0%
(0/6)
|
0%
(0/45)
|
| PushNotificationManager$setNotificationStyle$1$1 |
0%
(0/1)
|
0%
(0/4)
|
0%
(0/5)
|
0%
(0/43)
|
| PushNotificationManager$setNotificationStyle$1$2 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/6)
|
| PushNotificationManager$setText$1 |
0%
(0/1)
|
|
0%
(0/3)
|
0%
(0/11)
|
| PushNotificationManager$tryNotifyRemoteMessage$1 |
|
| PushNotificationManager$tryNotifyRemoteMessage$image$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/15)
|
0%
(0/63)
|
| Total |
14.3%
(5/35)
|
3.4%
(3/88)
|
4.7%
(18/384)
|
9%
(126/1395)
|
package cloud.mindbox.mobile_sdk.pushes
import android.app.Activity
import android.app.Notification
import android.app.Notification.DEFAULT_ALL
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import androidx.annotation.DrawableRes
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import cloud.mindbox.mobile_sdk.Mindbox
import cloud.mindbox.mobile_sdk.R
import cloud.mindbox.mobile_sdk.logger.mindboxLogE
import cloud.mindbox.mobile_sdk.logger.mindboxLogI
import cloud.mindbox.mobile_sdk.pushes.handler.MessageHandlingState
import cloud.mindbox.mobile_sdk.pushes.handler.MindboxMessageHandler
import cloud.mindbox.mobile_sdk.pushes.handler.image.ImageRetryStrategy
import cloud.mindbox.mobile_sdk.putMindboxPushButtonExtras
import cloud.mindbox.mobile_sdk.services.BackgroundWorkManager
import cloud.mindbox.mobile_sdk.utils.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.UnknownHostException
import kotlin.random.Random
internal object PushNotificationManager {
private const val EXTRA_NOTIFICATION_ID = "notification_id"
private const val EXTRA_URL = "push_url"
private const val EXTRA_PAYLOAD = "push_payload"
private const val MAX_ACTIONS_COUNT = 3
internal const val EXTRA_UNIQ_PUSH_KEY = "uniq_push_key"
internal const val EXTRA_UNIQ_PUSH_BUTTON_KEY = "uniq_push_button_key"
internal const val IS_OPENED_FROM_PUSH_BUNDLE_KEY = "isOpenedFromPush"
internal var messageHandler: MindboxMessageHandler = MindboxMessageHandler()
internal fun buildLogMessage(
message: MindboxRemoteMessage,
log: String,
): String = "Notify message ${message.uniqueKey}: $log"
internal fun isNotificationsEnabled(
context: Context,
): Boolean = LoggingExceptionHandler.runCatching(defaultValue = true) {
NotificationManagerCompat.from(context).areNotificationsEnabled()
}
@RequiresApi(Build.VERSION_CODES.M)
private fun isNotificationActive(
notificationManager: NotificationManager,
notificationId: Int,
): Boolean = LoggingExceptionHandler.runCatching(
defaultValue = false,
) {
notificationManager.activeNotifications.find { it.id == notificationId } != null
}
internal suspend fun handleRemoteMessage(
context: Context,
remoteMessage: MindboxRemoteMessage,
channelId: String,
channelName: String,
@DrawableRes pushSmallIcon: Int,
channelDescription: String?,
activities: Map<String, Class<out Activity>>?,
defaultActivity: Class<out Activity>,
): Boolean = LoggingExceptionHandler.runCatchingSuspending(defaultValue = false) {
Mindbox.onPushReceived(
context = context.applicationContext,
uniqKey = remoteMessage.uniqueKey,
)
tryNotifyRemoteMessage(
notificationId = Generator.generateUniqueInt(),
context = context,
remoteMessage = remoteMessage,
channelId = channelId,
channelName = channelName,
pushSmallIcon = pushSmallIcon,
channelDescription = channelDescription,
activities = activities,
defaultActivity = defaultActivity,
state = MessageHandlingState(
attemptNumber = 1,
isMessageDisplayed = false,
),
)
mindboxLogI("handleRemoteMessage success")
true
}
internal suspend fun tryNotifyRemoteMessage(
notificationId: Int,
context: Context,
remoteMessage: MindboxRemoteMessage,
channelId: String,
channelName: String,
@DrawableRes pushSmallIcon: Int,
channelDescription: String?,
activities: Map<String, Class<out Activity>>?,
defaultActivity: Class<out Activity>,
state: MessageHandlingState,
) {
mindboxLogI(
message = buildLogMessage(
message = remoteMessage,
log = "Started with state - $state",
),
)
val applicationContext = context.applicationContext
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (isNotificationCancelled(notificationManager, notificationId, state)) {
mindboxLogI(
message = buildLogMessage(
message = remoteMessage,
log = "An attempt to update the notification was canceled " +
"because the notification was deleted",
),
)
return
}
val image = withContext(Dispatchers.IO) {
runCatching {
val imageLoader = messageHandler.imageLoader
this@PushNotificationManager.mindboxLogI(
message = buildLogMessage(
message = remoteMessage,
log = "Image loading started, imageLoader=$imageLoader",
),
)
val bitmap = imageLoader.onLoadImage(
context = context,
message = remoteMessage,
state = state,
)
this@PushNotificationManager.mindboxLogI(
message = buildLogMessage(
message = remoteMessage,
log = "Image loading complete, bitmap=${bitmap?.byteCount} bytes",
),
)
bitmap
}
}
if (isNotificationCancelled(notificationManager, notificationId, state)) {
mindboxLogI(
message = buildLogMessage(
message = remoteMessage,
log = "An attempt to update the notification was canceled " +
"because the notification was deleted",
),
)
return
}
val fallback = image.exceptionOrNull()?.let { error ->
if (error is UnknownHostException) {
mindboxLogE(
message = buildLogMessage(
message = remoteMessage,
log = "Image loading failed:\n${error.stackTraceToString()}",
),
)
} else {
mindboxLogE(
message = buildLogMessage(
message = remoteMessage,
log = "Image loading failed:",
),
exception = error,
)
}
val imageFailureHandler = messageHandler.imageFailureHandler
mindboxLogI(
message = buildLogMessage(
message = remoteMessage,
log = "Image loading error will be handled in $imageFailureHandler",
),
)
imageFailureHandler.onImageLoadingFailed(
context = context,
message = remoteMessage,
state = state,
error = error,
).also {
mindboxLogI(
message = buildLogMessage(
message = remoteMessage,
log = "Solution for failed image loading - $it",
),
)
}
}
if (fallback is ImageRetryStrategy.ApplyDefaultAndRetry && Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
mindboxLogE(
message = buildLogMessage(
message = remoteMessage,
log = "ApplyDefaultAndRetry works correctly only on SDK >= 23",
),
)
}
when (fallback) {
is ImageRetryStrategy.Retry -> retryNotifyRemoteMessage(
context = context,
notificationId = notificationId,
remoteMessage = remoteMessage,
channelId = channelId,
channelName = channelName,
pushSmallIcon = pushSmallIcon,
channelDescription = channelDescription,
activities = activities,
defaultActivity = defaultActivity,
state = state,
delay = fallback.delay,
)
is ImageRetryStrategy.Cancel -> {}
is ImageRetryStrategy.ApplyDefaultAndRetry -> applyDefaultAndRetryNotifyRemoteMessage(
context = applicationContext,
notificationManager = notificationManager,
remoteMessage = remoteMessage,
channelId = channelId,
channelName = channelName,
channelDescription = channelDescription,
notificationId = notificationId,
pushSmallIcon = pushSmallIcon,
activities = activities,
defaultActivity = defaultActivity,
delay = fallback.delay,
imagePlaceholder = fallback.defaultImage,
currentState = state,
)
is ImageRetryStrategy.ApplyDefault -> applyDefaultNotifyRemoteMessage(
context = applicationContext,
notificationManager = notificationManager,
remoteMessage = remoteMessage,
channelId = channelId,
channelName = channelName,
channelDescription = channelDescription,
notificationId = notificationId,
pushSmallIcon = pushSmallIcon,
activities = activities,
defaultActivity = defaultActivity,
imagePlaceholder = fallback.defaultImage,
)
null -> {
notifyRemoteMessage(
context = applicationContext,
notificationManager = notificationManager,
remoteMessage = remoteMessage,
channelId = channelId,
channelName = channelName,
channelDescription = channelDescription,
notificationId = notificationId,
pushSmallIcon = pushSmallIcon,
activities = activities,
defaultActivity = defaultActivity,
image = image.getOrNull(),
)
mindboxLogI(
message = buildLogMessage(
message = remoteMessage,
log = "Successfully notified!",
),
)
}
}
}
private fun isNotificationCancelled(
notificationManager: NotificationManager,
notificationId: Int,
state: MessageHandlingState,
) = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
state.attemptNumber > 1 &&
state.isMessageDisplayed &&
!isNotificationActive(notificationManager, notificationId)
private fun retryNotifyRemoteMessage(
context: Context,
notificationId: Int,
remoteMessage: MindboxRemoteMessage,
channelId: String,
channelName: String,
pushSmallIcon: Int,
channelDescription: String?,
activities: Map<String, Class<out Activity>>?,
defaultActivity: Class<out Activity>,
state: MessageHandlingState,
delay: Long,
) = BackgroundWorkManager.startNotificationWork(
context = context,
notificationId = notificationId,
remoteMessage = remoteMessage,
channelId = channelId,
channelName = channelName,
pushSmallIcon = pushSmallIcon,
channelDescription = channelDescription,
activities = activities,
defaultActivity = defaultActivity,
delay = delay,
state = state,
)
private fun applyDefaultAndRetryNotifyRemoteMessage(
context: Context,
notificationManager: NotificationManager,
remoteMessage: MindboxRemoteMessage,
channelId: String,
channelName: String,
channelDescription: String?,
notificationId: Int,
pushSmallIcon: Int,
activities: Map<String, Class<out Activity>>?,
defaultActivity: Class<out Activity>,
delay: Long,
imagePlaceholder: Bitmap?,
currentState: MessageHandlingState,
) {
createNotificationChannel(
context = context,
channelId = channelId,
channelName = channelName,
channelDescription = channelDescription,
)
val notification = buildNotification(
context = context,
notificationId = notificationId,
uniqueKey = remoteMessage.uniqueKey,
title = remoteMessage.title,
text = remoteMessage.description,
pushActions = remoteMessage.pushActions,
pushLink = remoteMessage.pushLink,
payload = remoteMessage.payload,
image = imagePlaceholder,
channelId = channelId,
pushSmallIcon = pushSmallIcon,
activities = activities,
defaultActivity = defaultActivity,
)
notificationManager.notify(notificationId, notification)
BackgroundWorkManager.startNotificationWork(
context = context,
notificationId = notificationId,
remoteMessage = remoteMessage,
channelId = channelId,
channelName = channelName,
pushSmallIcon = pushSmallIcon,
channelDescription = channelDescription,
activities = activities,
defaultActivity = defaultActivity,
delay = delay,
state = currentState.copy(isMessageDisplayed = true),
)
}
private fun applyDefaultNotifyRemoteMessage(
context: Context,
notificationManager: NotificationManager,
remoteMessage: MindboxRemoteMessage,
channelId: String,
channelName: String,
channelDescription: String?,
notificationId: Int,
pushSmallIcon: Int,
activities: Map<String, Class<out Activity>>?,
defaultActivity: Class<out Activity>,
imagePlaceholder: Bitmap?,
) {
createNotificationChannel(
context = context,
channelId = channelId,
channelName = channelName,
channelDescription = channelDescription,
)
val notification = buildNotification(
context = context,
notificationId = notificationId,
uniqueKey = remoteMessage.uniqueKey,
title = remoteMessage.title,
text = remoteMessage.description,
pushActions = remoteMessage.pushActions,
pushLink = remoteMessage.pushLink,
payload = remoteMessage.payload,
image = imagePlaceholder,
channelId = channelId,
pushSmallIcon = pushSmallIcon,
activities = activities,
defaultActivity = defaultActivity,
)
notificationManager.notify(notificationId, notification)
}
private fun notifyRemoteMessage(
context: Context,
notificationManager: NotificationManager,
remoteMessage: MindboxRemoteMessage,
channelId: String,
channelName: String,
channelDescription: String?,
notificationId: Int,
pushSmallIcon: Int,
activities: Map<String, Class<out Activity>>?,
defaultActivity: Class<out Activity>,
image: Bitmap?,
) {
createNotificationChannel(
context = context,
channelId = channelId,
channelName = channelName,
channelDescription = channelDescription,
)
val notification = buildNotification(
context = context,
notificationId = notificationId,
uniqueKey = remoteMessage.uniqueKey,
title = remoteMessage.title,
text = remoteMessage.description,
pushActions = remoteMessage.pushActions,
pushLink = remoteMessage.pushLink,
payload = remoteMessage.payload,
image = image,
channelId = channelId,
pushSmallIcon = pushSmallIcon,
activities = activities,
defaultActivity = defaultActivity,
)
notificationManager.notify(notificationId, notification)
}
private fun buildNotification(
context: Context,
notificationId: Int,
uniqueKey: String,
title: String,
text: String,
pushActions: List<PushAction>,
pushLink: String?,
payload: String?,
image: Bitmap?,
channelId: String,
@DrawableRes pushSmallIcon: Int,
activities: Map<String, Class<out Activity>>?,
defaultActivity: Class<out Activity>,
): Notification {
val correctedLinksActivities = activities?.mapKeys { (key, _) ->
key.replace("*", ".*").toRegex()
}
val hasButtons = pushActions.isNotEmpty()
return NotificationCompat.Builder(context, channelId)
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(pushSmallIcon)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(DEFAULT_ALL)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setIconColor(context)
.handlePushClick(
context = context,
notificationId = notificationId,
uniqueKey = uniqueKey,
payload = payload,
pushLink = pushLink,
activities = correctedLinksActivities,
defaultActivity = defaultActivity,
)
.handleActions(
context = context,
notificationId = notificationId,
uniqueKey = uniqueKey,
payload = payload,
pushActions = pushActions,
activities = correctedLinksActivities,
defaultActivity = defaultActivity,
)
.setNotificationStyle(
image = image,
title = title,
text = text,
hasButtons = hasButtons,
context = context
)
.build()
}
internal fun getUrlFromPushIntent(intent: Intent) = intent.getStringExtra(EXTRA_URL)
internal fun getPayloadFromPushIntent(intent: Intent) = intent.getStringExtra(EXTRA_PAYLOAD)
private fun createNotificationChannel(
context: Context,
channelId: String,
channelName: String,
channelDescription: String?,
) = loggingRunCatching {
NotificationManagerCompat.from(context).createNotificationChannel(
NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(channelName)
.setDescription(channelDescription)
.build()
)
}
internal fun createPendingIntent(
context: Context,
activity: Class<out Activity>,
id: Int,
payload: String?,
pushKey: String,
url: String?,
pushButtonKey: String? = null,
extras: Bundle? = null,
): PendingIntent? = loggingRunCatching(defaultValue = null) {
val intent = Intent(context, activity).apply {
putExtra(EXTRA_PAYLOAD, payload)
putExtra(EXTRA_NOTIFICATION_ID, id)
putMindboxPushButtonExtras(pushKey, pushButtonKey)
url?.let { url -> putExtra(EXTRA_URL, url) }
`package` = context.packageName
extras?.let { putExtras(extras) }
}
PendingIntentCompat.getActivity(
context,
Random.nextInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT,
false,
)
}
private fun NotificationCompat.Builder.handlePushClick(
context: Context,
notificationId: Int,
uniqueKey: String,
payload: String?,
pushLink: String?,
activities: Map<Regex, Class<out Activity>>?,
defaultActivity: Class<out Activity>,
) = apply {
val activity = resolveActivity(activities, pushLink, defaultActivity)
createPendingIntent(
context = context,
activity = activity,
id = notificationId,
payload = payload,
pushKey = uniqueKey,
url = pushLink,
)?.let(this::setContentIntent)
}
private fun NotificationCompat.Builder.handleActions(
context: Context,
notificationId: Int,
uniqueKey: String,
payload: String?,
pushActions: List<PushAction>,
activities: Map<Regex, Class<out Activity>>?,
defaultActivity: Class<out Activity>,
) = apply {
runCatching {
pushActions.take(MAX_ACTIONS_COUNT).forEach { pushAction ->
val activity = resolveActivity(activities, pushAction.url, defaultActivity)
createPendingIntent(
context = context,
activity = activity,
id = notificationId,
pushKey = uniqueKey,
payload = payload,
url = pushAction.url,
pushButtonKey = pushAction.uniqueKey,
)?.let { addAction(0, pushAction.text ?: "", it) }
}
}
}
private fun resolveActivity(
activities: Map<Regex, Class<out Activity>>?,
link: String?,
defaultActivity: Class<out Activity>,
): Class<out Activity> {
val key = link?.let { activities?.keys?.find { it.matches(link) } }
return activities?.get(key) ?: defaultActivity
}
private fun NotificationCompat.Builder.setNotificationStyle(
image: Bitmap?,
title: String,
text: String?,
hasButtons: Boolean,
context: Context
) = apply {
LoggingExceptionHandler.runCatching(
block = {
if (image != null) {
val useScale = context.resources.getBoolean(R.bool.mindbox_use_central_inside_notification_scale)
val bigPicture = if (useScale) createCenterInsideBitmap(image, hasButtons, title.length) else image
setImage(image, bigPicture, title, text)
} else {
setText(text)
}
},
defaultValue = { setText(text) }
)
}
private fun NotificationCompat.Builder.setImage(
imageBitmap: Bitmap,
bigPicture: Bitmap,
title: String,
text: String?
): NotificationCompat.Builder {
setLargeIcon(imageBitmap)
val style = NotificationCompat.BigPictureStyle()
.bigPicture(bigPicture)
.bigLargeIcon(null as Bitmap?)
.setBigContentTitle(title)
text?.let(style::setSummaryText)
return setStyle(style)
}
private fun NotificationCompat.Builder.setText(
text: String?,
) = LoggingExceptionHandler.runCatching {
setStyle(
NotificationCompat.BigTextStyle()
.bigText(text),
)
}
private fun NotificationCompat.Builder.setIconColor(context: Context) = apply {
loggingRunCatching {
ContextCompat.getColor(context, R.color.mindbox_default_notification_color).takeIf {
it != Color.TRANSPARENT
}?.let { defaultColor ->
setColor(defaultColor)
mindboxLogI("Notification color overridden to ${Integer.toHexString(defaultColor)}")
}
}
}
private fun createCenterInsideBitmap(src: Bitmap, hasButtons: Boolean, charCountInTitle: Int): Bitmap {
return runCatching {
val targetWidth = imageWidthInPixels
val targetHeight =
if (hasButtons) getImageHeightWithButtonIxPixels(charCountInTitle) else getImageHeightWithoutButtonIxPixels(charCountInTitle)
mindboxLogI("Target dimensions: width=$targetWidth, height=$targetHeight")
if (targetWidth == 0 || targetHeight == 0) {
mindboxLogI("Target dimensions are zero. Returning original bitmap")
return src
}
val srcWidth = src.width
val srcHeight = src.height
val scale = minOf(targetWidth.toFloat() / srcWidth, targetHeight.toFloat() / srcHeight)
mindboxLogI("Source dimensions: width=$srcWidth, height=$srcHeight. Scale factor: $scale")
val scaledWidth = (srcWidth * scale).toInt()
val scaledHeight = (srcHeight * scale).toInt()
mindboxLogI("Scaled dimensions: width=$scaledWidth, height=$scaledHeight")
val result = Bitmap.createBitmap(targetWidth, scaledHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
canvas.drawColor(Color.TRANSPARENT)
val left = (targetWidth - scaledWidth) / 2
mindboxLogI("Drawing bitmap at position: left=$left")
if (left == 0) {
mindboxLogI("Left=0, we will get the same bitmap as the original. Returning original bitmap.")
return src
}
val scaledBitmap = Bitmap.createScaledBitmap(src, scaledWidth, scaledHeight, true)
canvas.drawBitmap(scaledBitmap, left.toFloat(), 0f, null)
return result
}.onFailure {
mindboxLogE("Error occurred during image scaling return original bitmap ", it)
}.getOrDefault(src)
}
}