diff --git a/app/schemas/io.heckel.ntfy.data.Database/6.json b/app/schemas/io.heckel.ntfy.data.Database/6.json index 1a7ffd2..8e481f8 100644 --- a/app/schemas/io.heckel.ntfy.data.Database/6.json +++ b/app/schemas/io.heckel.ntfy.data.Database/6.json @@ -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')" ] } } \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index e26abbb..332f012 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -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? diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index 6c4400a..f8cb3bf 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -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 diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt index 7c0c4e0..2ffaa10 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -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 ) diff --git a/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt index 1ae1239..a961869 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt @@ -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) } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt index e9e2314..8c2c03e 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -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" diff --git a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt index 9645329..9c30464 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -96,6 +96,7 @@ class FirebaseService : FirebaseMessagingService() { attachmentExpires = attachmentExpires, attachmentPreviewUrl = attachmentPreviewUrl, attachmentUrl = attachmentUrl, + attachmentContentUri = null, notificationId = Random.nextInt(), deleted = false )