diff --git a/app/build.gradle b/app/build.gradle index 90dc1ed..f610ded 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -36,6 +36,8 @@ android { debug { minifyEnabled false debuggable true + applicationIdSuffix ".debug" + versionNameSuffix "-debug" } } @@ -128,4 +130,14 @@ dependencies { // Image viewer implementation 'com.github.stfalcon-studio:StfalconImageViewer:v1.0.1' + + // Better click handling for links + implementation 'me.saket:better-link-movement-method:2.2.0' + + // Markdown + implementation 'io.noties.markwon:core:4.6.2' + implementation 'io.noties.markwon:image-picasso:4.6.2' + implementation 'io.noties.markwon:image:4.6.2' + implementation 'io.noties.markwon:ext-tables:4.6.2' + implementation 'io.noties.markwon:ext-strikethrough:4.6.2' } diff --git a/app/schemas/io.heckel.ntfy.db.Database/13.json b/app/schemas/io.heckel.ntfy.db.Database/13.json index 3a89d0f..1931538 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/13.json +++ b/app/schemas/io.heckel.ntfy.db.Database/13.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 13, - "identityHash": "44fc291d937fdf02b9bc2d0abb10d2e0", + "identityHash": "208f16743f21d9c374f1314878eb93cb", "entities": [ { "tableName": "Subscription", @@ -94,10 +94,10 @@ } ], "primaryKey": { + "autoGenerate": false, "columnNames": [ "id" - ], - "autoGenerate": false + ] }, "indices": [ { @@ -124,7 +124,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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `icon_url` TEXT, `icon_contentUri` TEXT, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, 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, `contentType` TEXT NOT NULL, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `icon_url` TEXT, `icon_contentUri` TEXT, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))", "fields": [ { "fieldPath": "id", @@ -156,6 +156,12 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": true + }, { "fieldPath": "encoding", "columnName": "encoding", @@ -255,11 +261,11 @@ } ], "primaryKey": { + "autoGenerate": false, "columnNames": [ "id", "subscriptionId" - ], - "autoGenerate": false + ] }, "indices": [], "foreignKeys": [] @@ -288,10 +294,10 @@ } ], "primaryKey": { + "autoGenerate": false, "columnNames": [ "baseUrl" - ], - "autoGenerate": false + ] }, "indices": [], "foreignKeys": [] @@ -338,10 +344,10 @@ } ], "primaryKey": { + "autoGenerate": true, "columnNames": [ "id" - ], - "autoGenerate": true + ] }, "indices": [], "foreignKeys": [] @@ -350,7 +356,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, '44fc291d937fdf02b9bc2d0abb10d2e0')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '208f16743f21d9c374f1314878eb93cb')" ] } } \ No newline at end of file diff --git a/app/src/debug/res/values/values.xml b/app/src/debug/res/values/values.xml new file mode 100644 index 0000000..674f6be --- /dev/null +++ b/app/src/debug/res/values/values.xml @@ -0,0 +1,4 @@ + + + ntfy (debug) + diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index 89460c4..a939fbe 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -183,6 +183,7 @@ class Backuper(val context: Context) { timestamp = n.timestamp, title = n.title, message = n.message, + contentType = n.contentType, encoding = n.encoding, notificationId = 0, priority = n.priority, @@ -312,6 +313,7 @@ class Backuper(val context: Context) { timestamp = n.timestamp, title = n.title, message = n.message, + contentType = n.contentType, encoding = n.encoding, priority = n.priority, tags = n.tags, @@ -386,6 +388,7 @@ data class Notification( val timestamp: Long, val title: String, val message: String, + val contentType: String, // "" or "text/markdown" (empty assumes "text/plain") val encoding: String, // "base64" or "" val priority: Int, // 1=min, 3=default, 5=max val tags: String, diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index 5f39796..baa5006 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -99,6 +99,7 @@ data class Notification( @ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "message") val message: String, + @ColumnInfo(name = "contentType") val contentType: String, // "" or "text/markdown" (empty assume text/plain) @ColumnInfo(name = "encoding") val encoding: String, // "base64" or "" @ColumnInfo(name = "notificationId") val notificationId: Int, // Android notification popup ID @ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max @@ -110,6 +111,10 @@ data class Notification( @ColumnInfo(name = "deleted") val deleted: Boolean, ) +fun Notification.isMarkdown(): Boolean { + return contentType == "text/markdown" +} + @Entity data class Attachment( @ColumnInfo(name = "name") val name: String, // Filename diff --git a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt index 6ad65b2..fb7f6d4 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt @@ -28,6 +28,7 @@ class BroadcastService(private val ctx: Context) { intent.putExtra("message", decodeMessage(notification)) intent.putExtra("message_bytes", decodeBytesMessage(notification)) intent.putExtra("message_encoding", notification.encoding) + intent.putExtra("content_type", notification.contentType) intent.putExtra("tags", notification.tags) intent.putExtra("tags_map", joinTagsMap(splitTags(notification.tags))) intent.putExtra("priority", notification.priority) diff --git a/app/src/main/java/io/heckel/ntfy/msg/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt index 8de0ab8..b34965f 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -1,6 +1,7 @@ package io.heckel.ntfy.msg import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName /* This annotation ensures that proguard still works in production builds, * see https://stackoverflow.com/a/62753300/1440785 */ @@ -17,6 +18,7 @@ data class Message( val actions: List?, val title: String?, val message: String, + @SerializedName("content_type") val contentType: String?, val encoding: String?, val attachment: MessageAttachment?, ) diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt index 81a60db..5af0741 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -57,6 +57,7 @@ class NotificationParser { timestamp = message.time, title = message.title ?: "", message = message.message, + contentType = message.contentType ?: "", encoding = message.encoding ?: "", priority = toPriority(message.priority), tags = joinTags(message.tags), 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 eec1e59..4806707 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -11,6 +11,8 @@ import android.media.RingtoneManager import android.net.Uri import android.os.Build import android.os.Bundle +import android.text.SpannedString +import android.text.style.CharacterStyle import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat @@ -26,6 +28,7 @@ import java.util.* class NotificationService(val context: Context) { private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val repository = Repository.getInstance(context) + private val markwon = MarkwonFactory.createForNotification(context) fun display(subscription: Subscription, notification: Notification) { Log.d(TAG, "Displaying notification $notification") @@ -147,7 +150,7 @@ class NotificationService(val context: Context) { try { val attachmentBitmap = contentUri.readBitmapFromUri(context) builder - .setContentText(maybeAppendActionErrors(formatMessage(notification), notification)) + .setContentText(maybeAppendActionErrors(maybeMarkdown(formatMessage(notification), notification), notification)) .setLargeIcon(attachmentBitmap) .setStyle(NotificationCompat.BigPictureStyle() .bigPicture(attachmentBitmap) @@ -167,8 +170,8 @@ class NotificationService(val context: Context) { } } - private fun formatMessageMaybeWithAttachmentInfos(notification: Notification): String { - val message = formatMessage(notification) + private fun formatMessageMaybeWithAttachmentInfos(notification: Notification): CharSequence { + val message = maybeMarkdown(formatMessage(notification), notification) val attachment = notification.attachment ?: return message val attachmentInfos = if (attachment.size != null) { "${attachment.name}, ${formatBytes(attachment.size)}" @@ -514,6 +517,13 @@ class NotificationService(val context: Context) { } } + private fun maybeMarkdown(message: String, notification: Notification): CharSequence { + if (notification.contentType == "text/markdown") { + return markwon.toMarkdown(message) + } + return message + } + companion object { const val ACTION_VIEW = "view" const val ACTION_HTTP = "http" diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index 0b56803..a5052a2 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -9,6 +9,8 @@ import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.text.method.LinkMovementMethod +import android.text.util.Linkify import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -33,20 +35,23 @@ import io.heckel.ntfy.msg.DownloadType import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW import io.heckel.ntfy.util.* +import io.noties.markwon.Markwon import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import me.saket.bettermovementmethod.BetterLinkMovementMethod class DetailAdapter(private val activity: Activity, private val lifecycleScope: CoroutineScope, private val repository: Repository, private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit) : ListAdapter(TopicDiffCallback) { + private val markwon: Markwon = MarkwonFactory.createForMessage(activity) val selected = mutableSetOf() // Notification IDs /* Creates and inflates view and return TopicViewHolder. */ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.fragment_detail_item, parent, false) - return DetailViewHolder(activity, lifecycleScope, repository, view, selected, onClick, onLongClick) + return DetailViewHolder(activity, lifecycleScope, repository, markwon, view, selected, onClick, onLongClick) } /* Gets current topic and uses it to bind view. */ @@ -73,7 +78,16 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: } /* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */ - class DetailViewHolder(private val activity: Activity, private val lifecycleScope: CoroutineScope, private val repository: Repository, itemView: View, private val selected: Set, val onClick: (Notification) -> Unit, val onLongClick: (Notification) -> Unit) : + class DetailViewHolder( + private val activity: Activity, + private val lifecycleScope: CoroutineScope, + private val repository: Repository, + private val markwon: Markwon, + itemView: View, + private val selected: Set, + val onClick: (Notification) -> Unit, + val onLongClick: (Notification) -> Unit + ) : RecyclerView.ViewHolder(itemView) { private var notification: Notification? = null private val layout: View = itemView.findViewById(R.id.detail_item_layout) @@ -100,7 +114,9 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: val unmatchedTags = unmatchedTags(splitTags(notification.tags)) dateView.text = formatDateShort(notification.timestamp) - messageView.text = maybeAppendActionErrors(formatMessage(notification), notification) + messageView.text = maybeAppendActionErrors(maybeMarkdown(formatMessage(notification), notification), notification) + messageView.autoLinkMask = if (notification.isMarkdown()) 0 else Linkify.WEB_URLS + messageView.movementMethod = BetterLinkMovementMethod.getInstance() messageView.setOnClickListener { // Click & Long-click listeners on the text as well, because "autoLink=web" makes them // clickable, and so we cannot rely on the underlying card to perform the action. @@ -143,6 +159,13 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: maybeRenderActions(context, notification) } + private fun maybeMarkdown(message: String, notification: Notification): CharSequence { + if (notification.isMarkdown()) { + return markwon.toMarkdown(message) + } + return message + } + private fun renderPriority(context: Context, notification: Notification) { when (notification.priority) { PRIORITY_MIN -> { diff --git a/app/src/main/java/io/heckel/ntfy/util/MarkwonFactory.kt b/app/src/main/java/io/heckel/ntfy/util/MarkwonFactory.kt new file mode 100644 index 0000000..f01e674 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/util/MarkwonFactory.kt @@ -0,0 +1,108 @@ +package io.heckel.ntfy.util + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.text.method.LinkMovementMethod +import android.text.style.* +import androidx.core.content.ContextCompat +import io.heckel.ntfy.R +import io.noties.markwon.* +import io.noties.markwon.core.CorePlugin +import io.noties.markwon.core.CoreProps +import io.noties.markwon.core.MarkwonTheme +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin +import io.noties.markwon.ext.tables.TableAwareMovementMethod +import io.noties.markwon.ext.tables.TablePlugin +import io.noties.markwon.ext.tables.TableTheme +import io.noties.markwon.movement.MovementMethodPlugin +import me.saket.bettermovementmethod.BetterLinkMovementMethod +import org.commonmark.ext.gfm.tables.TableCell +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.node.* +import org.commonmark.parser.Parser + +internal object MarkwonFactory { + fun createForMessage(context: Context): Markwon { + val headingSizes = floatArrayOf(1.7f, 1.5f, 1.2f, 1f, .8f, .7f) + val bulletGapWidth = (8 * context.resources.displayMetrics.density + 0.5f).toInt() + + return Markwon.builder(context) + .usePlugin(CorePlugin.create()) + .usePlugin(MovementMethodPlugin.create(BetterLinkMovementMethod.getInstance())) + // .usePlugin(PicassoImagesPlugin.create(picasso)) + .usePlugin(StrikethroughPlugin.create()) + //.usePlugin(TablePlugin.create(context)) + .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureTheme(builder: MarkwonTheme.Builder) { + builder.linkColor(ContextCompat.getColor(context, R.color.teal)) + .isLinkUnderlined(true) + } + override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { + builder + .setFactory(Heading::class.java) { _, props: RenderProps? -> + arrayOf( + RelativeSizeSpan(headingSizes[CoreProps.HEADING_LEVEL.require(props!!) - 1]), + StyleSpan(Typeface.BOLD) + ) + } + .setFactory(Emphasis::class.java) { _, _ -> StyleSpan(Typeface.ITALIC) } + .setFactory(StrongEmphasis::class.java) { _, _ -> StyleSpan(Typeface.BOLD) } + .setFactory(BlockQuote::class.java) { _, _ -> QuoteSpan() } + .setFactory(Code::class.java) { _, _ -> + arrayOf( + BackgroundColorSpan(Color.LTGRAY), + TypefaceSpan("monospace") + ) + } + .setFactory(ListItem::class.java) { _, _ -> BulletSpan(bulletGapWidth) } + } + + }) + .build() + } + + fun createForNotification(context: Context): Markwon { + val headingSizes = floatArrayOf(2f, 1.5f, 1.17f, 1f, .83f, .67f) + val bulletGapWidth = (8 * context.resources.displayMetrics.density + 0.5f).toInt() + + return Markwon.builder(context) + .usePlugin(CorePlugin.create()) + //.usePlugin(PicassoImagesPlugin.create(picasso)) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { + builder + .setFactory(Heading::class.java) { _, props: RenderProps? -> + arrayOf( + RelativeSizeSpan(headingSizes[CoreProps.HEADING_LEVEL.require(props!!) - 1]), + StyleSpan(Typeface.BOLD) + ) + } + .setFactory(Emphasis::class.java) { _, _ -> StyleSpan(Typeface.ITALIC) } + .setFactory(StrongEmphasis::class.java) { _, _ -> StyleSpan(Typeface.BOLD) } + .setFactory(BlockQuote::class.java) { _, _ -> QuoteSpan() } + .setFactory(Code::class.java) { _, _ -> + arrayOf( + BackgroundColorSpan(Color.LTGRAY), + TypefaceSpan("monospace") + ) + } + .setFactory(ListItem::class.java) { _, _ -> BulletSpan(bulletGapWidth) } + .setFactory(Link::class.java) { _, _ -> null } + } + + override fun configureParser(builder: Parser.Builder) { + builder.extensions(setOf(TablesExtension.create())) + } + + override fun configureVisitor(builder: MarkwonVisitor.Builder) { + builder.on(TableCell::class.java) { visitor: MarkwonVisitor, node: TableCell? -> + visitor.visitChildren(node!!) + visitor.builder().append(' ') + } + } + }) + .build() + } +} diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 2ffbb76..5ef2550 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -212,7 +212,7 @@ fun formatActionLabel(action: Action): String { } } -fun maybeAppendActionErrors(message: String, notification: Notification): String { +fun maybeAppendActionErrors(message: CharSequence, notification: Notification): CharSequence { val actionErrors = notification.actions .orEmpty() .mapNotNull { action -> action.error } 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 830b92d..4459df7 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -92,6 +92,7 @@ class FirebaseService : FirebaseMessagingService() { val click = data["click"] val iconUrl = data["icon"] val actions = data["actions"] // JSON array as string, sigh ... + val contentType = data["content_type"] val encoding = data["encoding"] val attachmentName = data["attachment_name"] ?: "attachment.bin" val attachmentType = data["attachment_type"] @@ -132,6 +133,7 @@ class FirebaseService : FirebaseMessagingService() { timestamp = timestamp, title = title ?: "", message = message, + contentType = contentType ?: "", encoding = encoding ?: "", priority = toPriority(priority), tags = tags ?: "", diff --git a/assets/logo_with_text.svg b/assets/logo_with_text.svg new file mode 100644 index 0000000..b0e4146 --- /dev/null +++ b/assets/logo_with_text.svg @@ -0,0 +1,265 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + ntfy.sh + + +