package org.pixeldroid.app.utils.notificationsWorker import android.Manifest import android.app.NotificationChannel import android.app.NotificationChannelGroup import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import org.pixeldroid.app.MainActivity import org.pixeldroid.app.R import org.pixeldroid.app.posts.PostActivity import org.pixeldroid.app.posts.fromHtml import org.pixeldroid.app.utils.PixelDroidApplication import org.pixeldroid.app.utils.api.PixelfedAPI.Companion.apiForUser import org.pixeldroid.app.utils.api.objects.Notification import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.* import org.pixeldroid.app.utils.api.objects.Status import org.pixeldroid.app.utils.db.AppDatabase import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity import org.pixeldroid.app.utils.di.PixelfedAPIHolder import org.pixeldroid.app.utils.getColorFromAttr import retrofit2.HttpException import java.io.IOException import java.time.Instant import javax.inject.Inject class NotificationsWorker( context: Context, params: WorkerParameters ) : CoroutineWorker(context, params) { @Inject lateinit var db: AppDatabase @Inject lateinit var apiHolder: PixelfedAPIHolder override suspend fun doWork(): Result { val users: List = db.userDao().getAll() for (user in users){ val uniqueUserId = makeChannelGroupId(user) val notificationsEnabledForUser = makeNotificationChannels( applicationContext, user.fullHandle, uniqueUserId ) //if notifications are disabled for this user, move on to next user if(!notificationsEnabledForUser) continue // Get newest notification from database var previouslyLatestNotification: Notification? = db.notificationDao().latestNotification(user.user_id, user.instance_uri) val api = apiForUser(user, db, apiHolder) try { // Request notifications from server var newNotifications: List? = api.notifications( since_id = previouslyLatestNotification?.id ) while (!newNotifications.isNullOrEmpty() && newNotifications.maxOf { it.created_at ?: Instant.MIN } > (previouslyLatestNotification?.created_at ?: Instant.MIN) ) { // Add to db val filteredNewNotifications: List = newNotifications.filter { (it.created_at ?: Instant.MIN) > (previouslyLatestNotification?.created_at ?: Instant.MIN) }.map { it.copy(user_id = user.user_id, instance_uri = user.instance_uri) }.sortedBy { it.created_at } db.notificationDao().insertAll(filteredNewNotifications) //If multiple notifications, show summary of them if(filteredNewNotifications.size > 1){ showNotificationSummary(filteredNewNotifications, uniqueUserId) } // Launch new notifications filteredNewNotifications.forEach { showNotification(it, user, uniqueUserId) } previouslyLatestNotification = filteredNewNotifications.maxByOrNull { it.created_at ?: Instant.MIN } // Request again newNotifications = api.notifications( since_id = previouslyLatestNotification?.id ) } } catch (exception: Exception) { return Result.failure() } } return Result.success() } private fun showNotificationSummary(notifications: List, uniqueUserId: String) { val content = joinNames( applicationContext, notifications.mapNotNull { it.account?.getDisplayName() } ) val title: String = applicationContext.resources.getQuantityString( R.plurals.notification_title_summary, notifications.size, notifications.size ) val groupBuilder = NotificationCompat.Builder(applicationContext, makeChannelId(uniqueUserId, null)) .setContentTitle(title) .setContentText(content) .setGroupSummary(true) .setAutoCancel(true) .setGroup(uniqueUserId) .setSmallIcon(R.drawable.notification_icon) .setStyle(NotificationCompat.BigTextStyle().bigText(content)) .setContentIntent( PendingIntent.getActivity(applicationContext, 0, Intent(applicationContext, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK putExtra(SHOW_NOTIFICATION_TAG, true) }, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) ) with(NotificationManagerCompat.from(applicationContext)) { if (ActivityCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED ) notify(uniqueUserId.hashCode(), groupBuilder.build()) } } private fun showNotification( notification: Notification, user: UserDatabaseEntity, uniqueUserId: String ) { val intent: Intent = when (notification.type) { mention -> notification.status?.let { Intent(applicationContext, PostActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK putExtra(Status.POST_TAG, notification.status) putExtra(Status.VIEW_COMMENTS_TAG, true) } } ?: Intent(applicationContext, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK putExtra(SHOW_NOTIFICATION_TAG, true) } else -> Intent(applicationContext, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK putExtra(SHOW_NOTIFICATION_TAG, true) } }.putExtra(USER_NOTIFICATION_TAG, user.user_id) .putExtra(INSTANCE_NOTIFICATION_TAG, user.instance_uri) val builder = NotificationCompat.Builder(applicationContext, makeChannelId(uniqueUserId, notification.type)) .setSmallIcon( when (notification.type) { follow, follow_request -> R.drawable.ic_follow mention -> R.drawable.mention_at_24dp reblog -> R.drawable.ic_reblog favourite -> R.drawable.ic_like_full comment, status -> R.drawable.ic_comment_empty poll -> R.drawable.poll null -> R.drawable.ic_comment_empty } ) .setColor(applicationContext.getColorFromAttr(R.attr.colorPrimary)) .setContentTitle( notification.account?.username?.let { username -> applicationContext.getString( when (notification.type) { follow -> R.string.followed_notification comment -> R.string.comment_notification mention -> R.string.mention_notification reblog -> R.string.shared_notification favourite -> R.string.liked_notification poll -> R.string.poll_notification null -> R.string.other_notification follow_request -> R.string.follow_request status -> R.string.status_notification } ).format(username) } ) .setPriority(NotificationCompat.PRIORITY_DEFAULT) // Set the intent that will fire when the user taps the notification .setContentIntent( PendingIntent.getActivity(applicationContext, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) ) .setAutoCancel(true) if (notification.type == mention || notification.type == comment || notification.type == poll){ builder.setContentText(notification.status?.content?.let { fromHtml(it) }) } builder.setGroup(uniqueUserId) with(NotificationManagerCompat.from(applicationContext)) { // notificationId is a unique int for each notification if (ActivityCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED ) notify((uniqueUserId + notification.id).hashCode(), builder.build()) } } companion object { const val SHOW_NOTIFICATION_TAG = "org.pixeldroid.app.SHOW_NOTIFICATION" const val INSTANCE_NOTIFICATION_TAG = "org.pixeldroid.app.USER_NOTIFICATION" const val USER_NOTIFICATION_TAG = "org.pixeldroid.app.INSTANCE_NOTIFICATION" const val otherNotificationType = "other" } } fun makeNotificationChannels(context: Context, handle: String, channelGroupId: String): Boolean { val notificationManager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // Create the NotificationChannel, but only on API 26+ because // the NotificationChannel class is new and not in the support library if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // The id of the group, hashed (since when creating the group, it may be truncated if too long) val hashedGroupId = channelGroupId.hashCode().toString() notificationManager.createNotificationChannelGroup(NotificationChannelGroup(hashedGroupId, handle)) val importance = NotificationManager.IMPORTANCE_DEFAULT val channels: List = listOf( NotificationChannel(makeChannelId(channelGroupId, follow), context.getString(R.string.followed_notification_channel), importance), NotificationChannel(makeChannelId(channelGroupId, mention), context.getString(R.string.mention_notification_channel), importance), NotificationChannel(makeChannelId(channelGroupId, reblog), context.getString(R.string.shared_notification_channel), importance), NotificationChannel(makeChannelId(channelGroupId, favourite), context.getString(R.string.liked_notification_channel), importance), NotificationChannel(makeChannelId(channelGroupId, comment), context.getString(R.string.comment_notification_channel), importance), NotificationChannel(makeChannelId(channelGroupId, poll), context.getString(R.string.poll_notification_channel), importance), NotificationChannel(makeChannelId(channelGroupId, null), context.getString(R.string.other_notification_channel), importance), ).map { it.apply { group = hashedGroupId } } // Register the channels with the system notificationManager.createNotificationChannels(channels) //Return true if notifications are enabled, false if disabled return notificationManager.areNotificationsEnabled() and (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val channelGroup = notificationManager.getNotificationChannelGroup(hashedGroupId) !channelGroup.isBlocked } else true) and (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { !notificationManager.areNotificationsPaused() } else true) and !channels.all { notificationManager.getNotificationChannel(it.id).importance <= NotificationManager.IMPORTANCE_NONE } } return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { notificationManager.areNotificationsEnabled() } else { true } } /** * [channelGroupId] is the id used to uniquely identify the group: for us it is a unique id * identifying a user consisting of the concatenation of the instance uri and user id * (see [makeChannelGroupId]). */ private fun makeChannelId(channelGroupId: String, type: Notification.NotificationType?): String = (channelGroupId + (type ?: NotificationsWorker.otherNotificationType)).hashCode().toString() fun makeChannelGroupId(user: UserDatabaseEntity) = user.instance_uri + user.user_id fun removeNotificationChannelsFromAccount(context: Context, user: UserDatabaseEntity?) = user?.let { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val notificationManager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val channelGroupId = makeChannelGroupId(user) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { notificationManager.deleteNotificationChannelGroup(channelGroupId.hashCode().toString()) } else { val types: MutableList = entries.toMutableList() types += null types.forEach { notificationManager.deleteNotificationChannel(makeChannelId(channelGroupId, it)) } } } } /** * BidiFormatter.unicodeWrap is insufficient in some cases (see Tusky#1921) * So we force isolation manually * https://unicode.org/reports/tr9/#Explicit_Directional_Isolates */ fun CharSequence.unicodeWrap(): String = "\u2068${this}\u2069" private fun joinNames(context: Context, notifications: List): String { return when { notifications.size > 3 -> { context.getString(R.string.notification_summary_large).format( *notifications.subList(0, 3).map { it.unicodeWrap() }.toTypedArray(), notifications.size - 3 ) } else -> context.getString( when(notifications.size) { 2 -> R.string.notification_summary_small else /* ==3 */-> R.string.notification_summary_medium }).format(*notifications.map { it.unicodeWrap() }.toTypedArray()) } }