- Auto download toggle

- Do not update notification if not visible
- Detail view menu
This commit is contained in:
Philipp Heckel 2022-01-09 22:08:29 -05:00
parent 95e101eb65
commit e88f87390e
19 changed files with 273 additions and 68 deletions

View file

@ -68,9 +68,13 @@ data class Attachment(
@ColumnInfo(name = "progress") val progress: Int,
) {
constructor(name: String?, type: String?, size: Long?, expires: Long?, url: String) :
this(name, type, size, expires, url, null, 0)
this(name, type, size, expires, url, null, PROGRESS_NONE)
}
const val PROGRESS_NONE = -1
const val PROGRESS_INDETERMINATE = -2
const val PROGRESS_DONE = 100
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 6)
abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao

View file

@ -161,6 +161,16 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
return sharedPrefs.getInt(SHARED_PREFS_MIN_PRIORITY, 1) // 1/low means all priorities
}
fun getAutoDownloadEnabled(): Boolean {
return sharedPrefs.getBoolean(SHARED_PREFS_AUTO_DOWNLOAD_ENABLED, true) // Enabled by default
}
fun setAutoDownloadEnabled(enabled: Boolean) {
sharedPrefs.edit()
.putBoolean(SHARED_PREFS_AUTO_DOWNLOAD_ENABLED, enabled)
.apply()
}
fun getBroadcastEnabled(): Boolean {
return sharedPrefs.getBoolean(SHARED_PREFS_BROADCAST_ENABLED, true) // Enabled by default
}
@ -291,6 +301,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil"
const val SHARED_PREFS_MIN_PRIORITY = "MinPriority"
const val SHARED_PREFS_AUTO_DOWNLOAD_ENABLED = "AutoDownload"
const val SHARED_PREFS_BROADCAST_ENABLED = "BroadcastEnabled"
const val SHARED_PREFS_UNIFIED_PUSH_ENABLED = "UnifiedPushEnabled"
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL"

View file

@ -8,11 +8,7 @@ import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Attachment
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.msg.NotificationService.Companion.PROGRESS_DONE
import io.heckel.ntfy.data.*
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
@ -33,12 +29,12 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
val repository = app.repository
val notification = repository.getNotification(notificationId) ?: return Result.failure()
val subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
val attachment = notification.attachment ?: return Result.failure()
downloadAttachment(repository, subscription, notification, attachment)
downloadAttachment(repository, subscription, notification)
return Result.success()
}
private fun downloadAttachment(repository: Repository, subscription: Subscription, notification: Notification, attachment: Attachment) {
private fun downloadAttachment(repository: Repository, subscription: Subscription, notification: Notification) {
val attachment = notification.attachment ?: return
Log.d(TAG, "Downloading attachment from ${attachment.url}")
val request = Request.Builder()
@ -71,8 +67,11 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
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)
val progress = if (size > 0) (bytesCopied.toFloat()/size.toFloat()*100).toInt() else PROGRESS_INDETERMINATE
val newAttachment = attachment.copy(progress = progress)
val newNotification = notification.copy(attachment = newAttachment)
notifier.update(subscription, newNotification)
repository.updateNotification(newNotification)
lastProgress = System.currentTimeMillis()
}
fileOut.write(buffer, 0, bytes)
@ -81,10 +80,10 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
}
}
Log.d(TAG, "Attachment download: successful response, proceeding with download")
val newAttachment = attachment.copy(contentUri = uri.toString())
val newAttachment = attachment.copy(contentUri = uri.toString(), progress = PROGRESS_DONE)
val newNotification = notification.copy(attachment = newAttachment)
repository.updateNotification(newNotification)
notifier.update(subscription, newNotification, progress = PROGRESS_DONE)
notifier.update(subscription, newNotification)
}
}

View file

@ -50,7 +50,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
}
private fun shouldDownload(subscription: Subscription, notification: Notification): Boolean {
return notification.attachment != null
return notification.attachment != null && repository.getAutoDownloadEnabled()
}
private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean {
@ -84,12 +84,10 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
}
private fun scheduleAttachmentDownload(notification: Notification) {
Log.d(TAG, "Enqueuing work to download attachment (+ preview if available)")
Log.d(TAG, "Enqueuing work to download attachment")
val workManager = WorkManager.getInstance(context)
val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java)
.setInputData(workDataOf(
"id" to notification.id,
))
.setInputData(workDataOf("id" to notification.id))
.build()
workManager.enqueue(workRequest)
}

View file

@ -24,15 +24,21 @@ class NotificationService(val context: Context) {
displayInternal(subscription, notification)
}
fun update(subscription: Subscription, notification: Notification, progress: Int = PROGRESS_NONE) {
Log.d(TAG, "Updating notification $notification")
displayInternal(subscription, notification, update = true, progress = progress)
fun update(subscription: Subscription, notification: Notification) {
val active = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
notificationManager.activeNotifications.find { it.id == notification.notificationId } != null
} else {
true
}
if (active) {
Log.d(TAG, "Updating notification $notification")
displayInternal(subscription, notification, update = true)
}
}
fun cancel(notification: Notification) {
if (notification.notificationId != 0) {
Log.d(TAG, "Cancelling notification ${notification.id}: ${notification.message}")
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notification.notificationId)
}
}
@ -41,9 +47,9 @@ class NotificationService(val context: Context) {
(1..5).forEach { priority -> maybeCreateNotificationChannel(priority) }
}
private fun displayInternal(subscription: Subscription, notification: Notification, update: Boolean = false, progress: Int = PROGRESS_NONE) {
private fun displayInternal(subscription: Subscription, notification: Notification, update: Boolean = false) {
val title = formatTitle(subscription, notification)
val message = maybeWithAttachmentInfo(formatMessage(notification), notification, progress)
val message = maybeWithAttachmentInfo(formatMessage(notification), notification)
val channelId = toChannelId(notification.priority)
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification)
@ -55,7 +61,7 @@ class NotificationService(val context: Context) {
setStyle(builder, notification, message) // Preview picture or big text style
setContentIntent(builder, subscription, notification)
maybeSetSound(builder, update)
maybeSetProgress(builder, progress)
maybeSetProgress(builder, notification)
maybeAddOpenAction(builder, notification)
maybeAddBrowseAction(builder, notification)
@ -63,16 +69,17 @@ class NotificationService(val context: Context) {
notificationManager.notify(notification.notificationId, builder.build())
}
private fun maybeWithAttachmentInfo(message: String, notification: Notification, progress: Int): String {
if (progress < 0 || notification.attachment == null) return message
val att = notification.attachment
// FIXME duplicate code
private fun maybeWithAttachmentInfo(message: String, notification: Notification): String {
val att = notification.attachment ?: return message
if (att.progress < 0) return message
val infos = mutableListOf<String>()
if (att.name != null) infos.add(att.name)
if (att.size != null) infos.add(formatBytes(att.size))
//if (att.expires != null && att.expires != 0L) infos.add(formatDateShort(att.expires))
if (progress in 0..99) infos.add("${progress}%")
if (att.progress in 0..99) infos.add("${att.progress}%")
if (infos.size == 0) return message
if (progress < 100) return "Downloading ${infos.joinToString(", ")}\n${message}"
if (att.progress < 100) return "Downloading ${infos.joinToString(", ")}\n${message}"
return "${message}\nFile: ${infos.joinToString(", ")}"
}
@ -120,9 +127,10 @@ class NotificationService(val context: Context) {
}
}
private fun maybeSetProgress(builder: NotificationCompat.Builder, progress: Int) {
private fun maybeSetProgress(builder: NotificationCompat.Builder, notification: Notification) {
val progress = notification.attachment?.progress
if (progress in 0..99) {
builder.setProgress(100, progress, false)
builder.setProgress(100, progress!!, false)
} else {
builder.setProgress(0, 0, false) // Remove progress bar
}
@ -206,10 +214,6 @@ class NotificationService(val context: Context) {
}
companion object {
const val PROGRESS_NONE = -1
const val PROGRESS_INDETERMINATE = -2
const val PROGRESS_DONE = 100
private const val TAG = "NtfyNotifService"
private const val CHANNEL_ID_MIN = "ntfy-min"

View file

@ -87,8 +87,8 @@ class SubscriberConnection(
fun cancel() {
Log.d(TAG, "[$url] Cancelling connection")
job?.cancel()
call?.cancel()
if (this::job.isInitialized) job?.cancel()
if (this::call.isInitialized) call?.cancel()
}
private fun nextRetryMillis(retryMillis: Long, startTime: Long): Long {

View file

@ -351,7 +351,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
Log.e(TAG, "Error fetching notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}: ${e.stackTrace}", e)
runOnUiThread {
Toast
.makeText(this@DetailActivity, getString(R.string.refresh_message_error, e.message), Toast.LENGTH_LONG)
.makeText(this@DetailActivity, getString(R.string.refresh_message_error_one, e.message), Toast.LENGTH_LONG)
.show()
mainListContainer.isRefreshing = false
}

View file

@ -1,23 +1,32 @@
package io.heckel.ntfy.ui
import android.app.DownloadManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.app.NotificationCompat
import android.widget.*
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.workDataOf
import io.heckel.ntfy.R
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.msg.AttachmentDownloadWorker
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.util.*
import java.util.*
class DetailAdapter(private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit) :
ListAdapter<Notification, DetailAdapter.DetailViewHolder>(TopicDiffCallback) {
val selected = mutableSetOf<String>() // Notification IDs
@ -51,13 +60,15 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
private val titleView: TextView = itemView.findViewById(R.id.detail_item_title_text)
private val messageView: TextView = itemView.findViewById(R.id.detail_item_message_text)
private val newImageView: View = itemView.findViewById(R.id.detail_item_new_dot)
private val tagsView: TextView = itemView.findViewById(R.id.detail_item_tags)
private val tagsView: TextView = itemView.findViewById(R.id.detail_item_tags_text)
private val imageView: ImageView = itemView.findViewById(R.id.detail_item_image)
private val attachmentView: TextView = itemView.findViewById(R.id.detail_item_attachment_text)
private val menuButton: ImageButton = itemView.findViewById(R.id.detail_item_menu_button)
fun bind(notification: Notification) {
this.notification = notification
val ctx = itemView.context
val context = itemView.context
val unmatchedTags = unmatchedTags(splitTags(notification.tags))
dateView.text = Date(notification.timestamp * 1000).toString()
@ -73,7 +84,7 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
}
if (unmatchedTags.isNotEmpty()) {
tagsView.visibility = View.VISIBLE
tagsView.text = ctx.getString(R.string.detail_item_tags, unmatchedTags.joinToString(", "))
tagsView.text = context.getString(R.string.detail_item_tags, unmatchedTags.joinToString(", "))
} else {
tagsView.visibility = View.GONE
}
@ -83,29 +94,29 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
when (notification.priority) {
1 -> {
priorityImageView.visibility = View.VISIBLE
priorityImageView.setImageDrawable(ContextCompat.getDrawable(ctx, R.drawable.ic_priority_1_24dp))
priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_1_24dp))
}
2 -> {
priorityImageView.visibility = View.VISIBLE
priorityImageView.setImageDrawable(ContextCompat.getDrawable(ctx, R.drawable.ic_priority_2_24dp))
priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_2_24dp))
}
3 -> {
priorityImageView.visibility = View.GONE
}
4 -> {
priorityImageView.visibility = View.VISIBLE
priorityImageView.setImageDrawable(ContextCompat.getDrawable(ctx, R.drawable.ic_priority_4_24dp))
priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_4_24dp))
}
5 -> {
priorityImageView.visibility = View.VISIBLE
priorityImageView.setImageDrawable(ContextCompat.getDrawable(ctx, R.drawable.ic_priority_5_24dp))
priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_5_24dp))
}
}
// 📄
val contentUri = notification.attachment?.contentUri
if (contentUri != null && supportedImage(notification.attachment.type)) {
val fileExists = if (contentUri != null) fileExists(context, contentUri) else false
if (contentUri != null && fileExists && supportedImage(notification.attachment.type)) {
try {
val resolver = itemView.context.applicationContext.contentResolver
val resolver = context.applicationContext.contentResolver
val bitmapStream = resolver.openInputStream(Uri.parse(contentUri))
val bitmap = BitmapFactory.decodeStream(bitmapStream)
imageView.setImageBitmap(bitmap)
@ -116,6 +127,61 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
} else {
imageView.visibility = View.GONE
}
if (notification.attachment != null) {
attachmentView.text = formatAttachmentInfo(notification, fileExists)
attachmentView.visibility = View.VISIBLE
menuButton.visibility = View.VISIBLE
menuButton.setOnClickListener { menuView ->
val popup = PopupMenu(context, menuView)
popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu)
val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download)
val openItem = popup.menu.findItem(R.id.detail_item_menu_open)
val browseItem = popup.menu.findItem(R.id.detail_item_menu_browse)
val copyUrlItem = popup.menu.findItem(R.id.detail_item_menu_copy_url)
if (contentUri != null && fileExists) {
openItem.setOnMenuItemClickListener {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(contentUri))) // FIXME try/catch
true
}
browseItem.setOnMenuItemClickListener {
context.startActivity(Intent(DownloadManager.ACTION_VIEW_DOWNLOADS))
true
}
copyUrlItem.setOnMenuItemClickListener {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("attachment url", notification.attachment.url)
clipboard.setPrimaryClip(clip)
Toast
.makeText(context, context.getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG)
.show()
true
}
downloadItem.isVisible = false
} else {
openItem.isVisible = false
browseItem.isVisible = false
downloadItem.setOnMenuItemClickListener {
scheduleAttachmentDownload(context, notification)
true
}
}
popup.show()
}
} else {
attachmentView.visibility = View.GONE
menuButton.visibility = View.GONE
}
}
private fun scheduleAttachmentDownload(context: Context, notification: Notification) {
Log.d(TAG, "Enqueuing work to download attachment")
val workManager = WorkManager.getInstance(context)
val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java)
.setInputData(workDataOf("id" to notification.id))
.build()
workManager.enqueue(workRequest)
}
}
@ -128,4 +194,8 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
return oldItem == newItem
}
}
companion object {
const val TAG = "NtfyDetailAdapter"
}
}

View file

@ -118,12 +118,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
startPeriodicPollWorker()
startPeriodicServiceRestartWorker()
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) {
/*if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1234);
}
else {
Toast.makeText(this, "Permission already granted", Toast.LENGTH_SHORT).show();
}
}*/
}
override fun onRequestPermissionsResult(requestCode: Int,
permissions: Array<String>,

View file

@ -106,6 +106,26 @@ class SettingsActivity : AppCompatActivity() {
}
}
// Auto download
val autoDownloadPrefId = context?.getString(R.string.settings_notifications_auto_download_key) ?: return
val autoDownload: SwitchPreference? = findPreference(autoDownloadPrefId)
autoDownload?.isChecked = repository.getAutoDownloadEnabled()
autoDownload?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
repository.setAutoDownloadEnabled(value)
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return repository.getAutoDownloadEnabled()
}
}
autoDownload?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
if (pref.isChecked) {
getString(R.string.settings_notifications_auto_download_summary_on)
} else {
getString(R.string.settings_notifications_auto_download_summary_off)
}
}
// Broadcast enabled
val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return
val broadcastEnabled: SwitchPreference? = findPreference(broadcastEnabledPrefId)

View file

@ -2,8 +2,11 @@ package io.heckel.ntfy.util
import android.animation.ArgbEvaluator
import android.animation.ValueAnimator
import android.content.Context
import android.net.Uri
import android.view.Window
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.PROGRESS_NONE
import io.heckel.ntfy.data.Subscription
import java.security.SecureRandom
import java.text.DateFormat
@ -106,6 +109,36 @@ fun formatTitle(notification: Notification): String {
}
}
// FIXME duplicate code
fun formatAttachmentInfo(notification: Notification, fileExists: Boolean): String {
if (notification.attachment == null) return ""
val att = notification.attachment
val infos = mutableListOf<String>()
if (att.name != null) infos.add(att.name)
if (att.size != null) infos.add(formatBytes(att.size))
//if (att.expires != null && att.expires != 0L) infos.add(formatDateShort(att.expires))
if (att.progress in 0..99) infos.add("${att.progress}%")
if (!fileExists) {
if (att.progress == PROGRESS_NONE) infos.add("not downloaded")
else infos.add("deleted")
}
if (infos.size == 0) return ""
if (att.progress < 100) return "Downloading ${infos.joinToString(", ")}"
return "\uD83D\uDCC4 " + infos.joinToString(", ")
}
// Checks in the most horrible way if a content URI exists; I couldn't find a better way
fun fileExists(context: Context, uri: String): Boolean {
val resolver = context.applicationContext.contentResolver
return try {
val fileIS = resolver.openInputStream(Uri.parse(uri))
fileIS?.close()
true
} catch (_: Exception) {
false
}
}
// Status bar color fading to match action bar, see https://stackoverflow.com/q/51150077/1440785
fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
val statusBarColorAnimation = ValueAnimator.ofObject(ArgbEvaluator(), fromColor, toColor)

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"
android:fillColor="#555555"/>
</vector>

View file

@ -29,6 +29,14 @@
android:layout_marginTop="1dp"
app:layout_constraintStart_toEndOf="@id/detail_item_priority_image"
android:layout_marginStart="5dp"/>
<ImageButton
android:layout_width="46dp"
android:layout_height="26dp" app:srcCompat="@drawable/ic_more_horiz_gray_24dp"
android:id="@+id/detail_item_menu_button"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="5dp"
android:background="?android:attr/selectableItemBackground" android:paddingTop="-5dp"
android:layout_marginTop="5dp"/>
<TextView
android:text="This is a very very very long message. It could be as long as 1024 charaters, which is a lot more than you'd think. No, really so far this message is barely 180 characters long. I can't believe how long 1024 bytes are. This is outrageous. Oh you know what, I think I won't type the whole thing. This seems a little too long for a sample text. Well, anyway, it was nice chatting. So far this message is about 400 bytes long. So maybe just double what you see and that's that."
android:layout_width="0dp"
@ -40,16 +48,6 @@
app:layout_constraintTop_toBottomOf="@id/detail_item_title_text"
app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="10dp"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="10dp"
app:layout_constraintBottom_toTopOf="@+id/detail_item_tags"/>
<TextView
android:text="Tags: ssh, zfs"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:id="@+id/detail_item_tags"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="10dp"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="10dp"
app:layout_constraintTop_toBottomOf="@id/detail_item_message_text"
app:layout_constraintBottom_toTopOf="@id/detail_item_image"/>
<TextView
android:text="This is an optional title. It can also be a little longer but not too long."
@ -70,13 +68,43 @@
app:layout_constraintStart_toEndOf="@+id/detail_item_date_text"
app:layout_constraintTop_toTopOf="@+id/detail_item_date_text"
app:layout_constraintBottom_toBottomOf="@+id/detail_item_date_text" android:layout_marginStart="5dp"/>
<ImageView
<com.google.android.material.imageview.ShapeableImageView
android:layout_width="fill_parent"
android:layout_height="wrap_content" app:srcCompat="@drawable/ic_cancel_gray_24dp"
android:id="@+id/detail_item_image" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/detail_item_tags"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/detail_item_message_text"
android:layout_marginStart="10dp" android:layout_marginEnd="10dp"
app:layout_constraintBottom_toBottomOf="parent" android:scaleType="centerCrop"
android:adjustViewBounds="true" android:maxHeight="150dp" android:layout_marginTop="5dp"/>
android:scaleType="centerCrop"
android:adjustViewBounds="true" android:maxHeight="150dp" android:layout_marginTop="5dp"
app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_text" android:layout_marginBottom="5dp"
app:shapeAppearanceOverlay="@style/roundedCornersImageView" android:visibility="visible"/>
<TextView
android:text="📄attachment.jpg, 20.1 KB"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:id="@+id/detail_item_attachment_text"
android:textColor="@color/primaryTextColor"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:autoLink="web"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="10dp"
app:layout_constraintTop_toBottomOf="@id/detail_item_image"
app:layout_constraintBottom_toTopOf="@+id/detail_item_tags_text" android:layout_marginTop="3dp"/>
<TextView
android:text="Tags: ssh, zfs"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:id="@+id/detail_item_tags_text"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="10dp"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="10dp"
app:layout_constraintTop_toBottomOf="@id/detail_item_attachment_text"
app:layout_constraintBottom_toTopOf="@id/detail_item_padding_bottom"/>
<TextView
android:layout_width="match_parent"
android:layout_height="10dp" android:id="@+id/detail_item_padding_bottom"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/detail_item_tags_text"
app:layout_constraintTop_toBottomOf="@id/detail_item_tags_text"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/detail_item_menu_download" android:title="@string/detail_item_menu_download"/>
<item android:id="@+id/detail_item_menu_open" android:title="@string/detail_item_menu_open"/>
<item android:id="@+id/detail_item_menu_browse" android:title="@string/detail_item_menu_browse"/>
<item android:id="@+id/detail_item_menu_copy_url" android:title="@string/detail_item_menu_copy_url"/>
</menu>

View file

@ -24,6 +24,7 @@
<string name="refresh_message_result">%1$d notification(s) received</string>
<string name="refresh_message_no_results">Everything is up-to-date</string>
<string name="refresh_message_error">%1$d subscription(s) could not be refreshed\n\n%2$s</string>
<string name="refresh_message_error_one">Subscription could not be refreshed: %1$s</string>
<!-- Main activity: Action bar -->
<string name="main_action_bar_title">Subscribed topics</string>
@ -108,6 +109,11 @@
<string name="detail_instant_delivery_disabled">Instant delivery disabled</string>
<string name="detail_instant_info">Instant delivery is enabled</string>
<string name="detail_item_tags">Tags: %1$s</string>
<string name="detail_item_menu_open">Open file</string>
<string name="detail_item_menu_browse">Browse file</string>
<string name="detail_item_menu_download">Download file</string>
<string name="detail_item_menu_copy_url">Copy URL</string>
<string name="detail_item_menu_copy_url_copied">Copied URL to clipboard</string>
<!-- Detail activity: Action bar -->
<string name="detail_menu_notifications_enabled">Notifications enabled</string>
@ -166,6 +172,10 @@
<string name="settings_notifications_min_priority_default">Default priority and higher</string>
<string name="settings_notifications_min_priority_high">High priority and higher</string>
<string name="settings_notifications_min_priority_max">Only max priority</string>
<string name="settings_notifications_auto_download_key">AutoDownload</string>
<string name="settings_notifications_auto_download_title">Auto download attachments</string>
<string name="settings_notifications_auto_download_summary_on">Attachments are automatically downloaded</string>
<string name="settings_notifications_auto_download_summary_off">Attachments are not automatically downloaded</string>
<string name="settings_unified_push_header">UnifiedPush</string>
<string name="settings_unified_push_header_summary">Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.</string>
<string name="settings_unified_push_enabled_key">UnifiedPushEnabled</string>

View file

@ -6,4 +6,10 @@
<item name="android:statusBarColor">@color/primaryColor</item>
<item name="actionModeBackground">@color/primaryDarkColor</item>
</style>
<!-- Rounded corners in images: https://stackoverflow.com/a/61960983/1440785 -->
<style name="roundedCornersImageView" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">5dp</item>
</style>
</resources>

View file

@ -13,6 +13,10 @@
app:entries="@array/settings_notifications_min_priority_entries"
app:entryValues="@array/settings_notifications_min_priority_values"
app:defaultValue="1"/>
<SwitchPreference
app:key="@string/settings_notifications_auto_download_key"
app:title="@string/settings_notifications_auto_download_title"
app:enabled="true"/>
</PreferenceCategory>
<PreferenceCategory
app:title="@string/settings_unified_push_header"

View file

@ -8,6 +8,7 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Attachment
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.PROGRESS_NONE
import io.heckel.ntfy.msg.*
import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.util.toPriority

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>

After

Width:  |  Height:  |  Size: 306 B