- 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, @ColumnInfo(name = "progress") val progress: Int,
) { ) {
constructor(name: String?, type: String?, size: Long?, expires: Long?, url: String) : 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) @androidx.room.Database(entities = [Subscription::class, Notification::class], version = 6)
abstract class Database : RoomDatabase() { abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao 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 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 { fun getBroadcastEnabled(): Boolean {
return sharedPrefs.getBoolean(SHARED_PREFS_BROADCAST_ENABLED, true) // Enabled by default 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_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil" const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil"
const val SHARED_PREFS_MIN_PRIORITY = "MinPriority" 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_BROADCAST_ENABLED = "BroadcastEnabled"
const val SHARED_PREFS_UNIFIED_PUSH_ENABLED = "UnifiedPushEnabled" const val SHARED_PREFS_UNIFIED_PUSH_ENABLED = "UnifiedPushEnabled"
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL" 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.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Attachment import io.heckel.ntfy.data.*
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 okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -33,12 +29,12 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
val repository = app.repository val repository = app.repository
val notification = repository.getNotification(notificationId) ?: return Result.failure() val notification = repository.getNotification(notificationId) ?: return Result.failure()
val subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure() val subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
val attachment = notification.attachment ?: return Result.failure() downloadAttachment(repository, subscription, notification)
downloadAttachment(repository, subscription, notification, attachment)
return Result.success() 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}") Log.d(TAG, "Downloading attachment from ${attachment.url}")
val request = Request.Builder() val request = Request.Builder()
@ -71,8 +67,11 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
var lastProgress = 0L var lastProgress = 0L
while (bytes >= 0) { while (bytes >= 0) {
if (System.currentTimeMillis() - lastProgress > 500) { if (System.currentTimeMillis() - lastProgress > 500) {
val progress = if (size > 0) (bytesCopied.toFloat()/size.toFloat()*100).toInt() else NotificationService.PROGRESS_INDETERMINATE val progress = if (size > 0) (bytesCopied.toFloat()/size.toFloat()*100).toInt() else PROGRESS_INDETERMINATE
notifier.update(subscription, notification, progress = progress) val newAttachment = attachment.copy(progress = progress)
val newNotification = notification.copy(attachment = newAttachment)
notifier.update(subscription, newNotification)
repository.updateNotification(newNotification)
lastProgress = System.currentTimeMillis() lastProgress = System.currentTimeMillis()
} }
fileOut.write(buffer, 0, bytes) 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") 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) val newNotification = notification.copy(attachment = newAttachment)
repository.updateNotification(newNotification) 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 { 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 { 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) { 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 workManager = WorkManager.getInstance(context)
val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java) val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java)
.setInputData(workDataOf( .setInputData(workDataOf("id" to notification.id))
"id" to notification.id,
))
.build() .build()
workManager.enqueue(workRequest) workManager.enqueue(workRequest)
} }

View file

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

View file

@ -87,8 +87,8 @@ class SubscriberConnection(
fun cancel() { fun cancel() {
Log.d(TAG, "[$url] Cancelling connection") Log.d(TAG, "[$url] Cancelling connection")
job?.cancel() if (this::job.isInitialized) job?.cancel()
call?.cancel() if (this::call.isInitialized) call?.cancel()
} }
private fun nextRetryMillis(retryMillis: Long, startTime: Long): Long { 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) Log.e(TAG, "Error fetching notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}: ${e.stackTrace}", e)
runOnUiThread { runOnUiThread {
Toast 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() .show()
mainListContainer.isRefreshing = false mainListContainer.isRefreshing = false
} }

View file

@ -1,23 +1,32 @@
package io.heckel.ntfy.ui 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.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.*
import android.widget.TextView
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView 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.R
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.msg.AttachmentDownloadWorker
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.util.* import io.heckel.ntfy.util.*
import java.util.* import java.util.*
class DetailAdapter(private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit) : class DetailAdapter(private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit) :
ListAdapter<Notification, DetailAdapter.DetailViewHolder>(TopicDiffCallback) { ListAdapter<Notification, DetailAdapter.DetailViewHolder>(TopicDiffCallback) {
val selected = mutableSetOf<String>() // Notification IDs 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 titleView: TextView = itemView.findViewById(R.id.detail_item_title_text)
private val messageView: TextView = itemView.findViewById(R.id.detail_item_message_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 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 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) { fun bind(notification: Notification) {
this.notification = notification this.notification = notification
val ctx = itemView.context val context = itemView.context
val unmatchedTags = unmatchedTags(splitTags(notification.tags)) val unmatchedTags = unmatchedTags(splitTags(notification.tags))
dateView.text = Date(notification.timestamp * 1000).toString() dateView.text = Date(notification.timestamp * 1000).toString()
@ -73,7 +84,7 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
} }
if (unmatchedTags.isNotEmpty()) { if (unmatchedTags.isNotEmpty()) {
tagsView.visibility = View.VISIBLE 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 { } else {
tagsView.visibility = View.GONE tagsView.visibility = View.GONE
} }
@ -83,29 +94,29 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
when (notification.priority) { when (notification.priority) {
1 -> { 1 -> {
priorityImageView.visibility = View.VISIBLE 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 -> { 2 -> {
priorityImageView.visibility = View.VISIBLE 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 -> { 3 -> {
priorityImageView.visibility = View.GONE priorityImageView.visibility = View.GONE
} }
4 -> { 4 -> {
priorityImageView.visibility = View.VISIBLE 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 -> { 5 -> {
priorityImageView.visibility = View.VISIBLE 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 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 { try {
val resolver = itemView.context.applicationContext.contentResolver val resolver = context.applicationContext.contentResolver
val bitmapStream = resolver.openInputStream(Uri.parse(contentUri)) val bitmapStream = resolver.openInputStream(Uri.parse(contentUri))
val bitmap = BitmapFactory.decodeStream(bitmapStream) val bitmap = BitmapFactory.decodeStream(bitmapStream)
imageView.setImageBitmap(bitmap) imageView.setImageBitmap(bitmap)
@ -116,6 +127,61 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
} else { } else {
imageView.visibility = View.GONE 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 return oldItem == newItem
} }
} }
companion object {
const val TAG = "NtfyDetailAdapter"
}
} }

View file

@ -118,12 +118,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
startPeriodicPollWorker() startPeriodicPollWorker()
startPeriodicServiceRestartWorker() 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); ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1234);
} }
else { else {
Toast.makeText(this, "Permission already granted", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "Permission already granted", Toast.LENGTH_SHORT).show();
} }*/
} }
override fun onRequestPermissionsResult(requestCode: Int, override fun onRequestPermissionsResult(requestCode: Int,
permissions: Array<String>, 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 // Broadcast enabled
val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return
val broadcastEnabled: SwitchPreference? = findPreference(broadcastEnabledPrefId) val broadcastEnabled: SwitchPreference? = findPreference(broadcastEnabledPrefId)

View file

@ -2,8 +2,11 @@ package io.heckel.ntfy.util
import android.animation.ArgbEvaluator import android.animation.ArgbEvaluator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.content.Context
import android.net.Uri
import android.view.Window import android.view.Window
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.PROGRESS_NONE
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import java.security.SecureRandom import java.security.SecureRandom
import java.text.DateFormat 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 // Status bar color fading to match action bar, see https://stackoverflow.com/q/51150077/1440785
fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) { fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
val statusBarColorAnimation = ValueAnimator.ofObject(ArgbEvaluator(), fromColor, toColor) 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" android:layout_marginTop="1dp"
app:layout_constraintStart_toEndOf="@id/detail_item_priority_image" app:layout_constraintStart_toEndOf="@id/detail_item_priority_image"
android:layout_marginStart="5dp"/> 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 <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: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" android:layout_width="0dp"
@ -40,16 +48,6 @@
app:layout_constraintTop_toBottomOf="@id/detail_item_title_text" app:layout_constraintTop_toBottomOf="@id/detail_item_title_text"
app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="10dp" app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="10dp"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="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"/> app:layout_constraintBottom_toTopOf="@id/detail_item_image"/>
<TextView <TextView
android:text="This is an optional title. It can also be a little longer but not too long." 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_constraintStart_toEndOf="@+id/detail_item_date_text"
app:layout_constraintTop_toTopOf="@+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"/> 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_width="fill_parent"
android:layout_height="wrap_content" app:srcCompat="@drawable/ic_cancel_gray_24dp" android:layout_height="wrap_content" app:srcCompat="@drawable/ic_cancel_gray_24dp"
android:id="@+id/detail_item_image" app:layout_constraintStart_toStartOf="parent" 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" android:layout_marginStart="10dp" android:layout_marginEnd="10dp"
app:layout_constraintBottom_toBottomOf="parent" android:scaleType="centerCrop" android:scaleType="centerCrop"
android:adjustViewBounds="true" android:maxHeight="150dp" android:layout_marginTop="5dp"/> 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> </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_result">%1$d notification(s) received</string>
<string name="refresh_message_no_results">Everything is up-to-date</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">%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 --> <!-- Main activity: Action bar -->
<string name="main_action_bar_title">Subscribed topics</string> <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_delivery_disabled">Instant delivery disabled</string>
<string name="detail_instant_info">Instant delivery is enabled</string> <string name="detail_instant_info">Instant delivery is enabled</string>
<string name="detail_item_tags">Tags: %1$s</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 --> <!-- Detail activity: Action bar -->
<string name="detail_menu_notifications_enabled">Notifications enabled</string> <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_default">Default priority and higher</string>
<string name="settings_notifications_min_priority_high">High 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_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">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_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> <string name="settings_unified_push_enabled_key">UnifiedPushEnabled</string>

View file

@ -6,4 +6,10 @@
<item name="android:statusBarColor">@color/primaryColor</item> <item name="android:statusBarColor">@color/primaryColor</item>
<item name="actionModeBackground">@color/primaryDarkColor</item> <item name="actionModeBackground">@color/primaryDarkColor</item>
</style> </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> </resources>

View file

@ -13,6 +13,10 @@
app:entries="@array/settings_notifications_min_priority_entries" app:entries="@array/settings_notifications_min_priority_entries"
app:entryValues="@array/settings_notifications_min_priority_values" app:entryValues="@array/settings_notifications_min_priority_values"
app:defaultValue="1"/> 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>
<PreferenceCategory <PreferenceCategory
app:title="@string/settings_unified_push_header" 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.app.Application
import io.heckel.ntfy.data.Attachment import io.heckel.ntfy.data.Attachment
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.PROGRESS_NONE
import io.heckel.ntfy.msg.* import io.heckel.ntfy.msg.*
import io.heckel.ntfy.service.SubscriberService import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.util.toPriority 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