This commit is contained in:
Philipp Heckel 2022-01-06 01:05:57 +01:00
parent 63fff52fcf
commit 7961f9f9e2
7 changed files with 144 additions and 89 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "0c64bd96a759eb0d899cd251756d6c00",
"identityHash": "6fd36c6995d3ae734f4ba7c8beaf9a95",
"entities": [
{
"tableName": "Subscription",
@ -80,7 +80,7 @@
},
{
"tableName": "Notification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `attachmentName` TEXT, `attachmentType` TEXT, `attachmentSize` INTEGER, `attachmentExpires` INTEGER, `attachmentPreviewUrl` TEXT, `attachmentUrl` TEXT, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`, `subscriptionId`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `attachmentName` TEXT, `attachmentType` TEXT, `attachmentSize` INTEGER, `attachmentExpires` INTEGER, `attachmentPreviewUrl` TEXT, `attachmentUrl` TEXT, `attachmentContentUri` TEXT, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`, `subscriptionId`))",
"fields": [
{
"fieldPath": "id",
@ -173,6 +173,12 @@
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachmentContentUri",
"columnName": "attachmentContentUri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "deleted",
"columnName": "deleted",
@ -194,7 +200,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0c64bd96a759eb0d899cd251756d6c00')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6fd36c6995d3ae734f4ba7c8beaf9a95')"
]
}
}

View file

@ -58,6 +58,7 @@ data class Notification(
@ColumnInfo(name = "attachmentExpires") val attachmentExpires: Long?, // Unix timestamp
@ColumnInfo(name = "attachmentPreviewUrl") val attachmentPreviewUrl: String?,
@ColumnInfo(name = "attachmentUrl") val attachmentUrl: String?,
@ColumnInfo(name = "attachmentContentUri") val attachmentContentUri: String?,
@ColumnInfo(name = "deleted") val deleted: Boolean,
)
@ -220,6 +221,9 @@ interface NotificationDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun add(notification: Notification)
@Update(onConflict = OnConflictStrategy.IGNORE)
fun update(notification: Notification)
@Query("SELECT * FROM notification WHERE id = :notificationId")
fun get(notificationId: String): Notification?

View file

@ -104,6 +104,11 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
return true
}
fun updateNotification(notification: Notification) {
notificationDao.update(notification)
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun markAsDeleted(notificationId: String) {
@ -290,6 +295,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
const val SHARED_PREFS_UNIFIED_PUSH_ENABLED = "UnifiedPushEnabled"
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL"
const val PREVIEWS_CACHE_DIR = "Previews"
private const val TAG = "NtfyRepository"
private var instance: Repository? = null

View file

@ -127,6 +127,7 @@ class ApiService {
attachmentExpires = message.attachment?.expires,
attachmentPreviewUrl = message.attachment?.preview_url,
attachmentUrl = message.attachment?.url,
attachmentContentUri = null,
notificationId = Random.nextInt(),
deleted = false
)
@ -163,6 +164,7 @@ class ApiService {
attachmentExpires = message.attachment?.expires,
attachmentPreviewUrl = message.attachment?.preview_url,
attachmentUrl = message.attachment?.url,
attachmentContentUri = null,
notificationId = 0,
deleted = false
)

View file

@ -2,6 +2,7 @@ package io.heckel.ntfy.msg
import android.content.ContentValues
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
@ -9,15 +10,17 @@ import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
class AttachmentDownloadWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.SECONDS) // Total timeout for entire request
.callTimeout(5, TimeUnit.MINUTES) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
@ -28,16 +31,17 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
if (context.applicationContext !is Application) return Result.failure()
val notificationId = inputData.getString("id") ?: return Result.failure()
val app = context.applicationContext as Application
val notification = app.repository.getNotification(notificationId) ?: return Result.failure()
val subscription = app.repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
val repository = app.repository
val notification = repository.getNotification(notificationId) ?: return Result.failure()
val subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
if (notification.attachmentPreviewUrl != null) {
downloadPreview(subscription, notification)
downloadPreview(repository, subscription, notification)
}
downloadAttachment(subscription, notification)
downloadAttachment(repository, subscription, notification)
return Result.success()
}
private fun downloadPreview(subscription: Subscription, notification: Notification) {
private fun downloadPreview(repository: Repository, subscription: Subscription, notification: Notification) {
val url = notification.attachmentPreviewUrl ?: return
Log.d(TAG, "Downloading preview from $url")
@ -49,47 +53,17 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
if (!response.isSuccessful || response.body == null) {
throw Exception("Preview download failed: ${response.code}")
}
Log.d(TAG, "Preview download: successful response, proceeding with download")
/*val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
Log.d(TAG, "dir: $dir")
if (dir == null /*|| !dir.mkdirs()*/) {
throw Exception("Cannot access target storage dir")
}*/
val contentResolver = applicationContext.contentResolver
val contentValues = ContentValues()
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "flower.jpg")
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
val uri = contentResolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), contentValues)
?: throw Exception("Cannot get content URI")
val out = contentResolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
out.use { fileOut ->
val previewFile = File(applicationContext.cacheDir.absolutePath, "preview-" + notification.id)
Log.d(TAG, "Downloading preview to cache file: $previewFile")
FileOutputStream(previewFile).use { fileOut ->
response.body!!.byteStream().copyTo(fileOut)
}
/*
val file = File(context.cacheDir, "somefile")
context.openFileOutput(file.absolutePath, Context.MODE_PRIVATE).use { fileOut ->
response.body!!.byteStream().copyTo(fileOut)
}
val file = File(dir, "myfile.txt")
Log.d(TAG, "dir: $dir, file: $file")
FileOutputStream(file).use { fileOut ->
response.body!!.byteStream().copyTo(fileOut)
}*/
/*
context.openFileOutput(file.absolutePath, Context.MODE_PRIVATE).use { fileOut ->
response.body!!.byteStream().copyTo(fileOut)
}*/
//val bitmap = BitmapFactory.decodeFile(file.absolutePath)
Log.d(TAG, "now we would display the preview image")
//displayInternal(subscription, notification, bitmap)
Log.d(TAG, "Preview downloaded; updating notification")
notifier.update(subscription, notification)
}
}
private fun downloadAttachment(subscription: Subscription, notification: Notification) {
private fun downloadAttachment(repository: Repository, subscription: Subscription, notification: Notification) {
val url = notification.attachmentUrl ?: return
Log.d(TAG, "Downloading attachment from $url")
@ -103,6 +77,7 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
}
val name = notification.attachmentName ?: "attachment.bin"
val mimeType = notification.attachmentType ?: "application/octet-stream"
val size = notification.attachmentSize ?: 0
val resolver = applicationContext.contentResolver
val details = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
@ -115,10 +90,26 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
Log.d(TAG, "Starting download to content URI: $uri")
val out = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
out.use { fileOut ->
response.body!!.byteStream().copyTo(fileOut)
val fileIn = response.body!!.byteStream()
var bytesCopied: Long = 0
val buffer = ByteArray(8 * 1024)
var bytes = fileIn.read(buffer)
var lastProgress = 0L
while (bytes >= 0) {
if (System.currentTimeMillis() - lastProgress > 500) {
val progress = if (size > 0) (bytesCopied.toFloat()/size.toFloat()*100).toInt() else NotificationService.PROGRESS_INDETERMINATE
notifier.update(subscription, notification, progress = progress)
lastProgress = System.currentTimeMillis()
}
fileOut.write(buffer, 0, bytes)
bytesCopied += bytes
bytes = fileIn.read(buffer)
}
}
Log.d(TAG, "Attachment download: successful response, proceeding with download")
notifier.update(subscription, notification)
val newNotification = notification.copy(attachmentContentUri = uri.toString())
repository.updateNotification(newNotification)
notifier.update(subscription, newNotification)
}
}

View file

@ -7,6 +7,7 @@ import android.app.TaskStackBuilder
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
@ -18,13 +19,17 @@ import androidx.work.WorkManager
import androidx.work.workDataOf
import io.heckel.ntfy.R
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.ui.DetailActivity
import io.heckel.ntfy.ui.MainActivity
import io.heckel.ntfy.util.formatMessage
import io.heckel.ntfy.util.formatTitle
import java.io.File
class NotificationService(val context: Context) {
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
fun display(subscription: Subscription, notification: Notification) {
Log.d(TAG, "Displaying notification $notification")
@ -39,9 +44,9 @@ class NotificationService(val context: Context) {
}
}
fun update(subscription: Subscription, notification: Notification) {
fun update(subscription: Subscription, notification: Notification, progress: Int = PROGRESS_NONE) {
Log.d(TAG, "Updating notification $notification")
displayInternal(subscription, notification)
displayInternal(subscription, notification, update = true, progress = progress)
}
fun cancel(notification: Notification) {
@ -53,45 +58,94 @@ class NotificationService(val context: Context) {
}
fun createNotificationChannels() {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
(1..5).forEach { priority -> maybeCreateNotificationChannel(notificationManager, priority) }
(1..5).forEach { priority -> maybeCreateNotificationChannel(priority) }
}
private fun displayInternal(subscription: Subscription, notification: Notification, bitmap: Bitmap? = null) {
private fun displayInternal(subscription: Subscription, notification: Notification, update: Boolean = false, progress: Int = PROGRESS_NONE) {
val title = formatTitle(subscription, notification)
val message = formatMessage(notification)
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val channelId = toChannelId(notification.priority)
var notificationBuilder = NotificationCompat.Builder(context, channelId)
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setColor(ContextCompat.getColor(context, R.color.primaryColor))
.setContentTitle(title)
.setContentText(message)
.setSound(defaultSoundUri)
.setOnlyAlertOnce(true) // Do not vibrate or play sound if already showing (updates!)
.setAutoCancel(true) // Cancel when notification is clicked
notificationBuilder = setContentIntent(notificationBuilder, subscription, notification)
setStyle(builder, notification, message) // Preview picture or big text style
setContentIntent(builder, subscription, notification)
maybeSetSound(builder, update)
maybeSetProgress(builder, progress)
maybeAddOpenAction(builder, notification)
maybeAddCopyUrlAction(builder, notification)
if (notification.attachmentUrl != null) {
val viewIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse(notification.attachmentUrl)), 0)
val openIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse("content://media/external/file/39")), 0)
notificationBuilder
.addAction(NotificationCompat.Action.Builder(0, "Open", openIntent).build())
.addAction(NotificationCompat.Action.Builder(0, "Copy URL", viewIntent).build())
.addAction(NotificationCompat.Action.Builder(0, "Download", viewIntent).build())
}
notificationBuilder = if (bitmap != null) {
notificationBuilder
.setLargeIcon(bitmap)
.setStyle(NotificationCompat.BigPictureStyle()
.bigPicture(bitmap)
.bigLargeIcon(null))
maybeCreateNotificationChannel(notification.priority)
notificationManager.notify(notification.notificationId, builder.build())
}
private fun maybeSetSound(builder: NotificationCompat.Builder, update: Boolean) {
if (!update) {
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
builder.setSound(defaultSoundUri)
} else {
notificationBuilder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
builder.setSound(null)
}
}
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
maybeCreateNotificationChannel(notificationManager, notification.priority)
notificationManager.notify(notification.notificationId, notificationBuilder.build())
private fun setStyle(builder: NotificationCompat.Builder, notification: Notification, message: String) {
val previewFile = File(context.applicationContext.cacheDir.absolutePath, "preview-" + notification.id)
if (previewFile.exists()) {
try {
val bitmap = BitmapFactory.decodeFile(previewFile.absolutePath)
builder
.setLargeIcon(bitmap)
.setStyle(NotificationCompat.BigPictureStyle()
.bigPicture(bitmap)
.bigLargeIcon(null))
} catch (_: Exception) {
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
} else {
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
}
private fun setContentIntent(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification) {
if (notification.click == "") {
builder.setContentIntent(detailActivityIntent(subscription))
} else {
try {
val uri = Uri.parse(notification.click)
val viewIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, uri), 0)
builder.setContentIntent(viewIntent)
} catch (e: Exception) {
builder.setContentIntent(detailActivityIntent(subscription))
}
}
}
private fun maybeSetProgress(builder: NotificationCompat.Builder, progress: Int) {
if (progress >= 0) {
builder.setProgress(100, progress, false)
} else {
builder.setProgress(0, 0, false) // Remove progress bar
}
}
private fun maybeAddOpenAction(notificationBuilder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachmentContentUri != null) {
val contentUri = Uri.parse(notification.attachmentContentUri)
val openIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, contentUri), 0)
notificationBuilder.addAction(NotificationCompat.Action.Builder(0, "Open", openIntent).build())
}
}
private fun maybeAddCopyUrlAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachmentUrl != null) {
// XXXXXXXXx
val copyUrlIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse(notification.attachmentUrl)), 0)
builder.addAction(NotificationCompat.Action.Builder(0, "Copy URL", copyUrlIntent).build())
}
}
private fun scheduleAttachmentDownload(subscription: Subscription, notification: Notification) {
@ -105,19 +159,6 @@ class NotificationService(val context: Context) {
workManager.enqueue(workRequest)
}
private fun setContentIntent(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification): NotificationCompat.Builder? {
if (notification.click == "") {
return builder.setContentIntent(detailActivityIntent(subscription))
}
return try {
val uri = Uri.parse(notification.click)
val viewIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, uri), 0)
builder.setContentIntent(viewIntent)
} catch (e: Exception) {
builder.setContentIntent(detailActivityIntent(subscription))
}
}
private fun detailActivityIntent(subscription: Subscription): PendingIntent? {
val intent = Intent(context, DetailActivity::class.java)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
@ -131,7 +172,7 @@ class NotificationService(val context: Context) {
}
}
private fun maybeCreateNotificationChannel(notificationManager: NotificationManager, priority: Int) {
private fun maybeCreateNotificationChannel(priority: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Note: To change a notification channel, you must delete the old one and create a new one!
@ -179,6 +220,9 @@ class NotificationService(val context: Context) {
}
companion object {
const val PROGRESS_NONE = -1
const val PROGRESS_INDETERMINATE = -2
private const val TAG = "NtfyNotificationService"
private const val CHANNEL_ID_MIN = "ntfy-min"
private const val CHANNEL_ID_LOW = "ntfy-low"

View file

@ -96,6 +96,7 @@ class FirebaseService : FirebaseMessagingService() {
attachmentExpires = attachmentExpires,
attachmentPreviewUrl = attachmentPreviewUrl,
attachmentUrl = attachmentUrl,
attachmentContentUri = null,
notificationId = Random.nextInt(),
deleted = false
)