/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.pitchedapps.frost.services
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.BaseBundle
import android.os.Build
import android.os.Bundle
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import ca.allanwang.kau.utils.dpToPx
import ca.allanwang.kau.utils.string
import com.pitchedapps.frost.BuildConfig
import com.pitchedapps.frost.R
import com.pitchedapps.frost.activities.FrostWebActivity
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.db.NotificationDao
import com.pitchedapps.frost.db.latestEpoch
import com.pitchedapps.frost.db.saveNotifications
import com.pitchedapps.frost.enums.OverlayContext
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.parsers.FrostParser
import com.pitchedapps.frost.facebook.parsers.MessageParser
import com.pitchedapps.frost.facebook.parsers.NotifParser
import com.pitchedapps.frost.facebook.parsers.ParseNotification
import com.pitchedapps.frost.glide.FrostGlide
import com.pitchedapps.frost.glide.GlideApp
import com.pitchedapps.frost.settings.hasNotifications
import com.pitchedapps.frost.utils.ARG_USER_ID
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.frostEvent
import com.pitchedapps.frost.utils.isIndependent
import java.util.Locale
import kotlin.math.abs
/**
* Created by Allan Wang on 2017-07-08.
*
* Logic for build notifications, scheduling notifications, and showing notifications
*/
private val _40_DP = 40.dpToPx
/**
* Enum to handle notification creations
*/
enum class NotificationType(
val channelId: String,
private val overlayContext: OverlayContext,
private val fbItem: FbItem,
private val parser: FrostParser,
private val ringtoneProvider: (Prefs) -> String
) {
GENERAL(
NOTIF_CHANNEL_GENERAL,
OverlayContext.NOTIFICATION,
FbItem.NOTIFICATIONS,
NotifParser,
{ it.notificationRingtone }
),
MESSAGE(
NOTIF_CHANNEL_MESSAGES,
OverlayContext.MESSAGE,
FbItem.MESSAGES,
MessageParser,
{ it.messageRingtone }
);
private val groupPrefix = "frost_${name.toLowerCase(Locale.CANADA)}"
/**
* Optional binder to return the request bundle builder
*/
internal open fun bindRequest(
content: NotificationContent,
cookie: String
): (BaseBundle.() -> Unit)? = null
private fun bindRequest(intent: Intent, content: NotificationContent) {
val cookie = content.data.cookie ?: return
val binder = bindRequest(content, cookie) ?: return
val bundle = Bundle()
bundle.binder()
intent.putExtras(bundle)
}
/**
* Get unread data from designated parser
* Display notifications for those after old epoch
* Save new epoch
*
* Returns the number of notifications generated,
* or -1 if an error occurred
*/
suspend fun fetch(context: Context, data: CookieEntity, prefs: Prefs, notifDao: NotificationDao): Int {
val response = try {
parser.parse(data.cookie)
} catch (ignored: Exception) {
null
}
if (response == null) {
L.v { "$name notification data not found" }
return -1
}
/**
* Checks that the text doesn't contain any blacklisted keywords
*/
fun validText(text: String?): Boolean {
val t = text ?: return true
return prefs.notificationKeywords.none {
t.contains(it, true)
}
}
val notifContents = response.data.getUnreadNotifications(data).filter { notif ->
validText(notif.title) && validText(notif.text)
}
if (notifContents.isEmpty()) return 0
val userId = data.id
val prevLatestEpoch = notifDao.latestEpoch(userId, channelId)
L.v { "Notif $name prev epoch $prevLatestEpoch" }
if (!notifDao.saveNotifications(channelId, notifContents)) {
L.d { "Skip notifs for $name as saving failed" }
return -1
}
if (prevLatestEpoch == -1L && !BuildConfig.DEBUG) {
L.d { "Skipping first notification fetch" }
return 0 // do not notify the first time
}
val newNotifContents = notifContents.filter { it.timestamp > prevLatestEpoch }
if (newNotifContents.isEmpty()) {
L.d { "No new notifs found for $name" }
return 0
}
L.d { "${newNotifContents.size} new notifs found for $name" }
val notifs = newNotifContents.map { createNotification(context, it) }
frostEvent("Notifications", "Type" to name, "Count" to notifs.size)
if (notifs.size > 1)
summaryNotification(context, userId, notifs.size).notify(context)
val ringtone = ringtoneProvider(prefs)
notifs.forEachIndexed { i, notif ->
// Ring at most twice
notif.withAlert(context, i < 2, ringtone, prefs).notify(context)
}
return notifs.size
}
fun debugNotification(context: Context, data: CookieEntity) {
val content = NotificationContent(
data,
System.currentTimeMillis(),
"https://github.com/AllanWang/Frost-for-Facebook",
"Debug Notif",
"Test 123",
System.currentTimeMillis() / 1000,
"https://www.iconexperience.com/_img/v_collection_png/256x256/shadow/dog.png",
false
)
createNotification(context, content).notify(context)
}
/**
* Attach content related data to an intent
*/
fun putContentExtra(intent: Intent, content: NotificationContent): Intent {
// We will show the notification page for dependent urls. We can trigger a click next time
intent.data =
Uri.parse(if (content.href.isIndependent) content.href else FbItem.NOTIFICATIONS.url)
bindRequest(intent, content)
return intent
}
/**
* Create a generic content for the provided type and user id.
* No content related data is added
*/
fun createCommonIntent(context: Context, userId: Long): Intent {
val intent = Intent(context, FrostWebActivity::class.java)
intent.putExtra(ARG_USER_ID, userId)
overlayContext.put(intent)
return intent
}
/**
* Create and submit a new notification with the given [content]
*/
private fun createNotification(
context: Context,
content: NotificationContent
): FrostNotification =
with(content) {
val intent = createCommonIntent(context, content.data.id)
putContentExtra(intent, content)
val group = "${groupPrefix}_${data.id}"
val pendingIntent =
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
val notifBuilder = context.frostNotification(channelId)
.setContentTitle(title ?: context.string(R.string.frost_name))
.setContentText(text)
.setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_SOCIAL)
.setSubText(data.name)
.setGroup(group)
if (timestamp != -1L) notifBuilder.setWhen(timestamp * 1000)
L.v { "Notif load $content" }
if (profileUrl != null) {
try {
val profileImg = GlideApp.with(context)
.asBitmap()
.load(profileUrl)
.transform(FrostGlide.circleCrop)
.submit(_40_DP, _40_DP)
.get()
notifBuilder.setLargeIcon(profileImg)
} catch (e: Exception) {
L.e { "Failed to get image $profileUrl" }
}
}
FrostNotification(group, notifId, notifBuilder)
}
/**
* Create a summary notification to wrap the previous ones
* This will always produce sound, vibration, and lights based on preferences
* and will only show if we have at least 2 notifications
*/
private fun summaryNotification(context: Context, userId: Long, count: Int): FrostNotification {
val intent = createCommonIntent(context, userId)
intent.data = Uri.parse(fbItem.url)
val group = "${groupPrefix}_$userId"
val pendingIntent =
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
val notifBuilder = context.frostNotification(channelId)
.setContentTitle(context.string(R.string.frost_name))
.setContentText("$count ${context.string(fbItem.titleId)}")
.setGroup(group)
.setGroupSummary(true)
.setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_SOCIAL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notifBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
}
return FrostNotification(group, 1, notifBuilder)
}
}
/**
* Notification data holder
*/
data class NotificationContent(
// TODO replace data with userId?
val data: CookieEntity,
val id: Long,
val href: String,
val title: String? = null, // defaults to frost title
val text: String,
val timestamp: Long,
val profileUrl: String?,
val unread: Boolean
) {
val notifId = abs(id.toInt())
}
/**
* Wrapper for a complete notification builder and identifier
* which can be immediately notified when given a [Context]
*/
data class FrostNotification(
private val tag: String,
private val id: Int,
val notif: NotificationCompat.Builder
) {
fun withAlert(context: Context, enable: Boolean, ringtone: String, prefs: Prefs): FrostNotification {
notif.setFrostAlert(context, enable, ringtone, prefs)
return this
}
fun notify(context: Context) =
NotificationManagerCompat.from(context).notify(tag, id, notif.build())
}
fun Context.scheduleNotificationsFromPrefs(prefs: Prefs): Boolean {
val shouldSchedule = prefs.hasNotifications
return if (shouldSchedule) scheduleNotifications(prefs.notificationFreq)
else scheduleNotifications(-1)
}
fun Context.scheduleNotifications(minutes: Long): Boolean =
scheduleJob(NOTIFICATION_PERIODIC_JOB, minutes)
fun Context.fetchNotifications(): Boolean =
fetchJob(NOTIFICATION_JOB_NOW)