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 @@
+
+
+
+