From 1cf781b27b670c2c1d277ff3de230721242f87d4 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 11 Jan 2022 17:00:18 -0500 Subject: [PATCH] Request permissions for older versions; filename things; polishing --- app/src/main/AndroidManifest.xml | 23 ++- .../main/java/io/heckel/ntfy/data/Database.kt | 1 + .../java/io/heckel/ntfy/data/Repository.kt | 4 +- .../ntfy/msg/AttachmentDownloaderWorker.kt | 141 ++++++++++++------ .../heckel/ntfy/msg/NotificationDispatcher.kt | 2 + .../io/heckel/ntfy/msg/NotificationService.kt | 86 ++++++++--- .../ntfy/service/SubscriberConnection.kt | 32 ++-- .../heckel/ntfy/service/SubscriberService.kt | 30 ++-- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 3 +- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 78 +++++----- .../java/io/heckel/ntfy/ui/MainActivity.kt | 36 ----- .../io/heckel/ntfy/ui/SettingsActivity.kt | 43 +++++- app/src/main/java/io/heckel/ntfy/util/Util.kt | 22 ++- app/src/main/res/values/strings.xml | 17 ++- app/src/main/res/xml/file_paths.xml | 4 + 15 files changed, 345 insertions(+), 177 deletions(-) create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6dd5ca7..adb8646 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,7 +13,7 @@ - + + + + + + + + + + + + diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index 58d8511..90e0d9a 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -73,6 +73,7 @@ data class Attachment( const val PROGRESS_NONE = -1 const val PROGRESS_INDETERMINATE = -2 +const val PROGRESS_FAILED = -3 const val PROGRESS_DONE = 100 @androidx.room.Database(entities = [Subscription::class, Notification::class], version = 6) diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index abf7688..9d69112 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -1,6 +1,7 @@ package io.heckel.ntfy.data import android.content.SharedPreferences +import android.os.Build import android.util.Log import androidx.annotation.WorkerThread import androidx.lifecycle.* @@ -162,7 +163,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri } fun getAutoDownloadEnabled(): Boolean { - return sharedPrefs.getBoolean(SHARED_PREFS_AUTO_DOWNLOAD_ENABLED, true) // Enabled by default + val defaultEnabled = Build.VERSION.SDK_INT > Build.VERSION_CODES.P // Need to request permission on older versions + return sharedPrefs.getBoolean(SHARED_PREFS_AUTO_DOWNLOAD_ENABLED, defaultEnabled) } fun setAutoDownloadEnabled(enabled: Boolean) { diff --git a/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt index 079d9a6..c8be427 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt @@ -2,15 +2,24 @@ package io.heckel.ntfy.msg import android.content.ContentValues import android.content.Context +import android.os.Build import android.os.Environment +import android.os.Handler +import android.os.Looper import android.provider.MediaStore import android.util.Log +import android.widget.Toast +import androidx.core.content.FileProvider import androidx.work.Worker import androidx.work.WorkerParameters +import io.heckel.ntfy.BuildConfig +import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.* +import io.heckel.ntfy.util.queryFilename import okhttp3.OkHttpClient import okhttp3.Request +import java.io.File import java.util.concurrent.TimeUnit class AttachmentDownloadWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { @@ -37,58 +46,106 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam val attachment = notification.attachment ?: return Log.d(TAG, "Downloading attachment from ${attachment.url}") - val request = Request.Builder() - .url(attachment.url) - .addHeader("User-Agent", ApiService.USER_AGENT) - .build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful || response.body == null) { - throw Exception("Attachment download failed: ${response.code}") - } - val name = attachment.name - val size = attachment.size ?: 0 - val resolver = applicationContext.contentResolver - val details = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, name) - if (attachment.type != null) { - put(MediaStore.MediaColumns.MIME_TYPE, attachment.type) + try { + val request = Request.Builder() + .url(attachment.url) + .addHeader("User-Agent", ApiService.USER_AGENT) + .build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful || response.body == null) { + throw Exception("Attachment download failed: ${response.code}") } - put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) - put(MediaStore.MediaColumns.IS_DOWNLOAD, 1) - } - val uri = resolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), details) - ?: throw Exception("Cannot get content URI") - Log.d(TAG, "Starting download to content URI: $uri") - var bytesCopied: Long = 0 - val out = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") - out.use { fileOut -> - val fileIn = response.body!!.byteStream() - val buffer = ByteArray(8 * 1024) - var bytes = fileIn.read(buffer) - var lastProgress = 0L - while (bytes >= 0) { - if (System.currentTimeMillis() - lastProgress > 500) { - val progress = if (size > 0) (bytesCopied.toFloat()/size.toFloat()*100).toInt() else PROGRESS_INDETERMINATE - val newAttachment = attachment.copy(progress = progress) - val newNotification = notification.copy(attachment = newAttachment) - notifier.update(subscription, newNotification) - repository.updateNotification(newNotification) - lastProgress = System.currentTimeMillis() + val name = attachment.name + val size = attachment.size ?: 0 + val resolver = applicationContext.contentResolver + val uri = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + val file = ensureSafeNewFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), name) + FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file) + } else { + val details = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, name) + if (attachment.type != null) { + put(MediaStore.MediaColumns.MIME_TYPE, attachment.type) + } + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + put(MediaStore.MediaColumns.IS_DOWNLOAD, 1) } - fileOut.write(buffer, 0, bytes) - bytesCopied += bytes - bytes = fileIn.read(buffer) + resolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), details) + ?: throw Exception("Cannot get content URI") } + Log.d(TAG, "Starting download to content URI: $uri") + var bytesCopied: Long = 0 + val out = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") + out.use { fileOut -> + val fileIn = response.body!!.byteStream() + val buffer = ByteArray(8 * 1024) + var bytes = fileIn.read(buffer) + var lastProgress = 0L + while (bytes >= 0) { + if (System.currentTimeMillis() - lastProgress > 500) { + val progress = if (size > 0) (bytesCopied.toFloat()/size.toFloat()*100).toInt() else 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) + bytesCopied += bytes + bytes = fileIn.read(buffer) + } + } + Log.d(TAG, "Attachment download: successful response, proceeding with download") + val actualName = queryFilename(context, uri.toString(), name) + val newAttachment = attachment.copy( + name = actualName, + size = bytesCopied, + contentUri = uri.toString(), + progress = PROGRESS_DONE + ) + val newNotification = notification.copy(attachment = newAttachment) + repository.updateNotification(newNotification) + notifier.update(subscription, newNotification) } - Log.d(TAG, "Attachment download: successful response, proceeding with download") - val newAttachment = attachment.copy(contentUri = uri.toString(), size = bytesCopied, progress = PROGRESS_DONE) + } catch (e: Exception) { + Log.w(TAG, "Attachment download failed", e) + + val newAttachment = attachment.copy(progress = PROGRESS_FAILED) val newNotification = notification.copy(attachment = newAttachment) - repository.updateNotification(newNotification) notifier.update(subscription, newNotification) + repository.updateNotification(newNotification) + + // Toast in a Worker: https://stackoverflow.com/a/56428145/1440785 + val handler = Handler(Looper.getMainLooper()) + handler.postDelayed({ + Toast + .makeText(context, context.getString(R.string.detail_item_download_failed, e.message), Toast.LENGTH_LONG) + .show() + }, 200) } } + private fun ensureSafeNewFile(dir: File, name: String): File { + val safeName = name.replace("[^-_.()\\w]+".toRegex(), "_"); + val file = File(dir, safeName) + if (!file.exists()) { + return file + } + (1..1000).forEach { i -> + val newFile = File(dir, if (file.extension == "") { + "${file.nameWithoutExtension} ($i)" + } else { + "${file.nameWithoutExtension} ($i).${file.extension}" + }) + if (!newFile.exists()) { + return newFile + } + } + throw Exception("Cannot find safe file") + } + companion object { private const val TAG = "NtfyAttachDownload" + private const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 5f134fd..fa5a23d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -25,6 +25,8 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } fun dispatch(subscription: Subscription, notification: Notification) { + Log.d(TAG, "Dispatching $notification for subscription $subscription") + val muted = getMuted(subscription) val notify = shouldNotify(subscription, notification, muted) val broadcast = shouldBroadcast(subscription) 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 7bd1b13..3b92521 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -9,9 +9,12 @@ import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.workDataOf import io.heckel.ntfy.R +import io.heckel.ntfy.data.* import io.heckel.ntfy.data.Notification -import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.ui.DetailActivity import io.heckel.ntfy.ui.MainActivity import io.heckel.ntfy.util.* @@ -49,40 +52,25 @@ class NotificationService(val context: Context) { private fun displayInternal(subscription: Subscription, notification: Notification, update: Boolean = false) { val title = formatTitle(subscription, notification) - val message = maybeWithAttachmentInfo(formatMessage(notification), notification) val channelId = toChannelId(notification.priority) val builder = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_notification) .setColor(ContextCompat.getColor(context, R.color.primaryColor)) .setContentTitle(title) - .setContentText(message) .setOnlyAlertOnce(true) // Do not vibrate or play sound if already showing (updates!) .setAutoCancel(true) // Cancel when notification is clicked - setStyle(builder, notification, message) // Preview picture or big text style - setContentIntent(builder, subscription, notification) + setStyleAndText(builder, notification) // Preview picture or big text style + setClickAction(builder, subscription, notification) maybeSetSound(builder, update) maybeSetProgress(builder, notification) maybeAddOpenAction(builder, notification) maybeAddBrowseAction(builder, notification) + maybeAddDownloadAction(builder, notification) maybeCreateNotificationChannel(notification.priority) notificationManager.notify(notification.notificationId, builder.build()) } - // 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() - 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 (infos.size == 0) return message - if (att.progress < 100) return "Downloading ${infos.joinToString(", ")}\n${message}" - return "${message}\nFile: ${infos.joinToString(", ")}" - } - private fun maybeSetSound(builder: NotificationCompat.Builder, update: Boolean) { if (!update) { val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) @@ -92,7 +80,7 @@ class NotificationService(val context: Context) { } } - private fun setStyle(builder: NotificationCompat.Builder, notification: Notification, message: String) { + private fun setStyleAndText(builder: NotificationCompat.Builder, notification: Notification) { val contentUri = notification.attachment?.contentUri val isSupportedImage = supportedImage(notification.attachment?.type) if (contentUri != null && isSupportedImage) { @@ -101,19 +89,46 @@ class NotificationService(val context: Context) { val bitmapStream = resolver.openInputStream(Uri.parse(contentUri)) val bitmap = BitmapFactory.decodeStream(bitmapStream) builder + .setContentText(formatMessage(notification)) .setLargeIcon(bitmap) .setStyle(NotificationCompat.BigPictureStyle() .bigPicture(bitmap) .bigLargeIcon(null)) } catch (_: Exception) { - builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) + val message = formatMessageMaybeWithAttachmentInfo(notification) + builder + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) } } else { - builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) + val message = formatMessageMaybeWithAttachmentInfo(notification) + builder + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) } } - private fun setContentIntent(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification) { + private fun formatMessageMaybeWithAttachmentInfo(notification: Notification): String { + val message = formatMessage(notification) + val attachment = notification.attachment ?: return message + val infos = if (attachment.size != null) { + "${attachment.name}, ${formatBytes(attachment.size)}" + } else { + attachment.name + } + if (attachment.progress in 0..99) { + return context.getString(R.string.notification_popup_file_downloading, infos, attachment.progress, message) + } + if (attachment.progress == PROGRESS_DONE) { + return context.getString(R.string.notification_popup_file_download_successful, message, infos) + } + if (attachment.progress == PROGRESS_FAILED) { + return context.getString(R.string.notification_popup_file_download_failed, message, infos) + } + return context.getString(R.string.notification_popup_file, message, infos) + } + + private fun setClickAction(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification) { if (notification.click == "") { builder.setContentIntent(detailActivityIntent(subscription)) } else { @@ -140,6 +155,8 @@ class NotificationService(val context: Context) { if (notification.attachment?.contentUri != null) { val contentUri = Uri.parse(notification.attachment.contentUri) val intent = Intent(Intent.ACTION_VIEW, contentUri) + intent.setDataAndType(contentUri, notification.attachment.type ?: "application/octet-stream") // Required for Android <= P + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_open), pendingIntent).build()) } @@ -148,11 +165,33 @@ class NotificationService(val context: Context) { private fun maybeAddBrowseAction(builder: NotificationCompat.Builder, notification: Notification) { if (notification.attachment?.contentUri != null) { val intent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_browse), pendingIntent).build()) } } + private fun maybeAddDownloadAction(builder: NotificationCompat.Builder, notification: Notification) { + if (notification.attachment?.contentUri == null && listOf(PROGRESS_NONE, PROGRESS_FAILED).contains(notification.attachment?.progress)) { + val intent = Intent(context, DownloadBroadcastReceiver::class.java) + intent.putExtra("id", notification.id) + val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_download), pendingIntent).build()) + } + } + + class DownloadBroadcastReceiver : android.content.BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val id = intent.getStringExtra("id") ?: return + Log.d(TAG, "Enqueuing work to download attachment for notification $id") + val workManager = WorkManager.getInstance(context) + val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java) + .setInputData(workDataOf("id" to id)) + .build() + workManager.enqueue(workRequest) + } + } + private fun detailActivityIntent(subscription: Subscription): PendingIntent? { val intent = Intent(context, DetailActivity::class.java) intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) @@ -215,6 +254,7 @@ class NotificationService(val context: Context) { companion object { private const val TAG = "NtfyNotifService" + private const val DOWNLOAD_ATTACHMENT_ACTION = "io.heckel.ntfy.DOWNLOAD_ATTACHMENT" private const val CHANNEL_ID_MIN = "ntfy-min" private const val CHANNEL_ID_LOW = "ntfy-low" diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberConnection.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberConnection.kt index a5ed860..9a70b8b 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberConnection.kt @@ -3,6 +3,7 @@ package io.heckel.ntfy.service import android.util.Log import io.heckel.ntfy.data.ConnectionState import io.heckel.ntfy.data.Notification +import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.util.topicUrl @@ -11,15 +12,17 @@ import okhttp3.Call import java.util.concurrent.atomic.AtomicBoolean class SubscriberConnection( + private val repository: Repository, private val api: ApiService, private val baseUrl: String, private val sinceTime: Long, - private val subscriptions: Map, - private val stateChangeListener: (Collection, ConnectionState) -> Unit, + private val topicsToSubscriptionIds: Map, // Topic -> Subscription ID + private val stateChangeListener: (Collection, ConnectionState) -> Unit, private val notificationListener: (Subscription, Notification) -> Unit, private val serviceActive: () -> Boolean ) { - private val topicsStr = subscriptions.values.joinToString(separator = ",") { s -> s.topic } + private val subscriptionIds = topicsToSubscriptionIds.values + private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",") private val url = topicUrl(baseUrl, topicsStr) private var since: Long = sinceTime @@ -28,16 +31,17 @@ class SubscriberConnection( fun start(scope: CoroutineScope) { job = scope.launch(Dispatchers.IO) { - Log.d(TAG, "[$url] Starting connection for subscriptions: $subscriptions") + Log.d(TAG, "[$url] Starting connection for subscriptions: $topicsToSubscriptionIds") // Retry-loop: if the connection fails, we retry unless the job or service is cancelled/stopped var retryMillis = 0L while (isActive && serviceActive()) { - Log.d(TAG, "[$url] (Re-)starting connection for subscriptions: $subscriptions") + Log.d(TAG, "[$url] (Re-)starting connection for subscriptions: $topicsToSubscriptionIds") val startTime = System.currentTimeMillis() - val notify = { topic: String, notification: Notification -> + val notify = notify@ { topic: String, notification: Notification -> since = notification.timestamp - val subscription = subscriptions.values.first { it.topic == topic } + val subscriptionId = topicsToSubscriptionIds[topic] ?: return@notify + val subscription = repository.getSubscription(subscriptionId) ?: return@notify val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id) notificationListener(subscription, notificationWithSubscriptionId) } @@ -45,7 +49,7 @@ class SubscriberConnection( val fail = { e: Exception -> failed.set(true) if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection - stateChangeListener(subscriptions.values, ConnectionState.CONNECTING) + stateChangeListener(subscriptionIds, ConnectionState.CONNECTING) } } @@ -54,14 +58,14 @@ class SubscriberConnection( try { call = api.subscribe(baseUrl, topicsStr, since, notify, fail) while (!failed.get() && !call.isCanceled() && isActive && serviceActive()) { - stateChangeListener(subscriptions.values, ConnectionState.CONNECTED) + stateChangeListener(subscriptionIds, ConnectionState.CONNECTED) Log.d(TAG,"[$url] Connection is active (failed=$failed, callCanceled=${call.isCanceled()}, jobActive=$isActive, serviceStarted=${serviceActive()}") delay(CONNECTION_LOOP_DELAY_MILLIS) // Resumes immediately if job is cancelled } } catch (e: Exception) { Log.e(TAG, "[$url] Connection failed: ${e.message}", e) if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection - stateChangeListener(subscriptions.values, ConnectionState.CONNECTING) + stateChangeListener(subscriptionIds, ConnectionState.CONNECTING) } } @@ -77,10 +81,6 @@ class SubscriberConnection( } } - fun matches(otherSubscriptions: Map): Boolean { - return subscriptions.keys == otherSubscriptions.keys - } - fun since(): Long { return since } @@ -91,6 +91,10 @@ class SubscriberConnection( if (this::call.isInitialized) call?.cancel() } + fun matches(otherSubscriptionIds: Collection): Boolean { + return subscriptionIds.toSet() == otherSubscriptionIds.toSet() + } + private fun nextRetryMillis(retryMillis: Long, startTime: Long): Long { val connectionDurationMillis = System.currentTimeMillis() - startTime if (connectionDurationMillis > RETRY_RESET_AFTER_MILLIS) { diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index 447a4d2..dea593c 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -11,8 +11,6 @@ import android.os.SystemClock import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat -import androidx.work.Worker -import androidx.work.WorkerParameters import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.R import io.heckel.ntfy.app.Application @@ -140,37 +138,38 @@ class SubscriberService : Service() { private fun refreshConnections() = GlobalScope.launch(Dispatchers.IO) { - // Group subscriptions by base URL (Base URL -> Map Sub>. - // There is only one connection per base URL. - val subscriptions = repository.getSubscriptions() + // Group INSTANT subscriptions by base URL, there is only one connection per base URL + val instantSubscriptions = repository.getSubscriptions() .filter { s -> s.instant } - val subscriptionsByBaseUrl = subscriptions + val instantSubscriptionsByBaseUrl = instantSubscriptions // BaseUrl->Map[Topic->SubscriptionId] .groupBy { s -> s.baseUrl } - .mapValues { entry -> entry.value.associateBy { it.id } } + .mapValues { entry -> + entry.value.associate { subscription -> subscription.topic to subscription.id } + } Log.d(TAG, "Refreshing subscriptions") - Log.d(TAG, "- Subscriptions: $subscriptionsByBaseUrl") + Log.d(TAG, "- Subscriptions: $instantSubscriptionsByBaseUrl") Log.d(TAG, "- Active connections: $connections") // Start new connections and restart connections (if subscriptions have changed) - subscriptionsByBaseUrl.forEach { (baseUrl, subscriptions) -> + instantSubscriptionsByBaseUrl.forEach { (baseUrl, subscriptions) -> val connection = connections[baseUrl] var since = 0L - if (connection != null && !connection.matches(subscriptions)) { + if (connection != null && !connection.matches(subscriptions.values)) { since = connection.since() connections.remove(baseUrl) connection.cancel() } if (!connections.containsKey(baseUrl)) { val serviceActive = { -> isServiceStarted } - val connection = SubscriberConnection(api, baseUrl, since, subscriptions, ::onStateChanged, ::onNotificationReceived, serviceActive) + val connection = SubscriberConnection(repository, api, baseUrl, since, subscriptions, ::onStateChanged, ::onNotificationReceived, serviceActive) connections[baseUrl] = connection connection.start(this) } } // Close connections without subscriptions - val baseUrls = subscriptionsByBaseUrl.keys + val baseUrls = instantSubscriptionsByBaseUrl.keys connections.keys().toList().forEach { baseUrl -> if (!baseUrls.contains(baseUrl)) { val connection = connections.remove(baseUrl) @@ -182,12 +181,12 @@ class SubscriberService : Service() { if (connections.size > 0) { synchronized(this) { val title = getString(R.string.channel_subscriber_notification_title) - val text = when (subscriptions.size) { + val text = when (instantSubscriptions.size) { 1 -> getString(R.string.channel_subscriber_notification_text_one) 2 -> getString(R.string.channel_subscriber_notification_text_two) 3 -> getString(R.string.channel_subscriber_notification_text_three) 4 -> getString(R.string.channel_subscriber_notification_text_four) - else -> getString(R.string.channel_subscriber_notification_text_more, subscriptions.size) + else -> getString(R.string.channel_subscriber_notification_text_more, instantSubscriptions.size) } serviceNotification = createNotification(title, text) notificationManager?.notify(NOTIFICATION_SERVICE_ID, serviceNotification) @@ -195,8 +194,7 @@ class SubscriberService : Service() { } } - private fun onStateChanged(subscriptions: Collection, state: ConnectionState) { - val subscriptionIds = subscriptions.map { it.id } + private fun onStateChanged(subscriptionIds: Collection, state: ConnectionState) { repository.updateState(subscriptionIds, state) } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 21d6a8b..a9616f7 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -111,7 +111,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra val onNotificationClick = { n: Notification -> onNotificationClick(n) } val onNotificationLongClick = { n: Notification -> onNotificationLongClick(n) } - adapter = DetailAdapter(onNotificationClick, onNotificationLongClick) + adapter = DetailAdapter(this, onNotificationClick, onNotificationLongClick) mainList = findViewById(R.id.detail_notification_list) mainList.adapter = adapter @@ -298,6 +298,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) { lifecycleScope.launch(Dispatchers.IO) { + Log.d(TAG, "Setting subscription 'muted until' to $mutedUntilTimestamp") val subscription = repository.getSubscription(subscriptionId) val newSubscription = subscription?.copy(mutedUntil = mutedUntilTimestamp) newSubscription?.let { repository.updateSubscription(newSubscription) } 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 013d3a0..fcf4334 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -1,16 +1,20 @@ package io.heckel.ntfy.ui +import android.Manifest +import android.app.Activity import android.app.DownloadManager import android.content.* +import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri -import android.provider.OpenableColumns +import android.os.Build import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* +import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter @@ -20,16 +24,12 @@ import androidx.work.WorkManager import androidx.work.workDataOf import com.stfalcon.imageviewer.StfalconImageViewer import io.heckel.ntfy.R -import io.heckel.ntfy.data.Attachment -import io.heckel.ntfy.data.Notification -import io.heckel.ntfy.data.PROGRESS_DONE -import io.heckel.ntfy.data.PROGRESS_NONE +import io.heckel.ntfy.data.* import io.heckel.ntfy.msg.AttachmentDownloadWorker import io.heckel.ntfy.util.* import java.util.* - -class DetailAdapter(private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit) : +class DetailAdapter(private val activity: Activity, private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit) : ListAdapter(TopicDiffCallback) { val selected = mutableSetOf() // Notification IDs @@ -37,7 +37,7 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.fragment_detail_item, parent, false) - return DetailViewHolder(view, selected, onClick, onLongClick) + return DetailViewHolder(activity, view, selected, onClick, onLongClick) } /* Gets current topic and uses it to bind view. */ @@ -54,7 +54,7 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL } /* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */ - class DetailViewHolder(itemView: View, private val selected: Set, val onClick: (Notification) -> Unit, val onLongClick: (Notification) -> Unit) : + class DetailViewHolder(private val activity: Activity, itemView: View, private val selected: Set, val onClick: (Notification) -> Unit, val onLongClick: (Notification) -> Unit) : RecyclerView.ViewHolder(itemView) { private var notification: Notification? = null private val priorityImageView: ImageView = itemView.findViewById(R.id.detail_item_priority_image) @@ -189,19 +189,27 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL if (attachment.contentUri != null) { openItem.setOnMenuItemClickListener { try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(attachment.contentUri))) + val contentUri = Uri.parse(attachment.contentUri) + val intent = Intent(Intent.ACTION_VIEW, contentUri) + intent.setDataAndType(contentUri, attachment.type ?: "application/octet-stream") // Required for Android <= P + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.startActivity(intent) } catch (e: ActivityNotFoundException) { Toast - .makeText(context, context.getString(R.string.detail_item_cannot_open), Toast.LENGTH_LONG) + .makeText(context, context.getString(R.string.detail_item_cannot_open_not_found), Toast.LENGTH_LONG) + .show() + } catch (e: Exception) { + Toast + .makeText(context, context.getString(R.string.detail_item_cannot_open, e.message), Toast.LENGTH_LONG) .show() - } catch (_: Exception) { - // URI parse exception and others; we don't care! } true } } browseItem.setOnMenuItemClickListener { - context.startActivity(Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)) + val intent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.startActivity(intent) true } copyUrlItem.setOnMenuItemClickListener { @@ -214,6 +222,11 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL true } downloadItem.setOnMenuItemClickListener { + val requiresPermission = Build.VERSION.SDK_INT <= Build.VERSION_CODES.P && ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED + if (requiresPermission) { + ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD) + return@setOnMenuItemClickListener true + } scheduleAttachmentDownload(context, notification) true } @@ -229,10 +242,11 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL } private fun formatAttachmentDetails(context: Context, attachment: Attachment, exists: Boolean): String { - val name = queryAttachmentFilename(context, attachment) + val name = queryFilename(context, attachment.contentUri, attachment.name) val notYetDownloaded = !exists && attachment.progress == PROGRESS_NONE val downloading = !exists && attachment.progress in 0..99 val deleted = !exists && attachment.progress == PROGRESS_DONE + val failed = !exists && attachment.progress == PROGRESS_FAILED val expired = attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000 val expires = attachment.expires != null && attachment.expires > System.currentTimeMillis()/1000 val infos = mutableListOf() @@ -241,22 +255,24 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL } if (notYetDownloaded) { if (expired) { - infos.add("not downloaded, link expired") + infos.add(context.getString(R.string.detail_item_download_info_not_downloaded_expired)) } else if (expires) { - infos.add("not downloaded, expires ${formatDateShort(attachment.expires!!)}") + infos.add(context.getString(R.string.detail_item_download_info_not_downloaded_expires_x, formatDateShort(attachment.expires!!))) } else { - infos.add("not downloaded") + infos.add(context.getString(R.string.detail_item_download_info_not_downloaded)) } } else if (downloading) { - infos.add("${attachment.progress}% downloaded") + infos.add(context.getString(R.string.detail_item_download_info_downloading_x_percent, attachment.progress)) } else if (deleted) { if (expired) { - infos.add("deleted, link expired") + infos.add(context.getString(R.string.detail_item_download_info_deleted_expired)) } else if (expires) { - infos.add("deleted, link expires ${formatDateShort(attachment.expires!!)}") + infos.add(context.getString(R.string.detail_item_download_info_deleted_expires_x, formatDateShort(attachment.expires!!))) } else { - infos.add("deleted") + infos.add(context.getString(R.string.detail_item_download_info_deleted)) } + } else if (failed) { + infos.add(context.getString(R.string.detail_item_download_info_download_failed)) } return if (infos.size > 0) { "$name\n${infos.joinToString(", ")}" @@ -265,23 +281,6 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL } } - private fun queryAttachmentFilename(context: Context, attachment: Attachment): String { - if (attachment.contentUri == null) { - return attachment.name - } - try { - val resolver = context.applicationContext.contentResolver - val cursor = resolver.query(Uri.parse(attachment.contentUri), null, null, null, null) ?: return attachment.name - return cursor.use { c -> - val nameIndex = c.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) - c.moveToFirst() - c.getString(nameIndex) - } - } catch (_: Exception) { - return attachment.name - } - } - private fun maybeRenderAttachmentImage(context: Context, attachment: Attachment, image: Boolean) { if (!image) { attachmentImageView.visibility = View.GONE @@ -328,5 +327,6 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL companion object { const val TAG = "NtfyDetailAdapter" + const val REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD = 9876 } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index c2df4b8..b9a365e 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -117,44 +117,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc // Background things startPeriodicPollWorker() startPeriodicServiceRestartWorker() - - /*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, - grantResults: IntArray) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode == 1234) { // FIXME - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - Toast.makeText(this@MainActivity, "Camera Permission Granted", Toast.LENGTH_SHORT).show() - } else { - Toast.makeText(this@MainActivity, "Camera Permission Denied", Toast.LENGTH_SHORT).show() - } - } - } -/* - public static final int REQUEST_WRITE_STORAGE = 112; - fun requestPermission(Activity context) { - boolean hasPermission = (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED); - if (!hasPermission) { - ActivityCompat.requestPermissions(context, - new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, - REQUEST_WRITE_STORAGE); - } else { - // You are allowed to write external storage: - String path = Environment.getExternalStorageDirectory().getAbsolutePath() + "/new_folder"; - File storageDir = new File(path); - if (!storageDir.exists() && !storageDir.mkdirs()) { - // This should never happen - log handled exception! - } - } -*/ override fun onResume() { super.onResume() showHideNotificationMenuItems() diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index 49be1f6..9c6d430 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -1,13 +1,18 @@ package io.heckel.ntfy.ui +import android.Manifest import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.text.TextUtils import android.util.Log import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentManager import androidx.preference.* import androidx.preference.Preference.OnPreferenceClickListener @@ -20,6 +25,7 @@ import io.heckel.ntfy.util.toPriorityString class SettingsActivity : AppCompatActivity() { private val repository by lazy { (application as Application).repository } + private lateinit var fragment: SettingsFragment override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -28,9 +34,10 @@ class SettingsActivity : AppCompatActivity() { Log.d(TAG, "Create $this") if (savedInstanceState == null) { + fragment = SettingsFragment(repository, supportFragmentManager) supportFragmentManager .beginTransaction() - .replace(R.id.settings_layout, SettingsFragment(repository, supportFragmentManager)) + .replace(R.id.settings_layout, fragment) .commit() } @@ -125,6 +132,16 @@ class SettingsActivity : AppCompatActivity() { getString(R.string.settings_notifications_auto_download_summary_off) } } + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + autoDownload?.setOnPreferenceChangeListener { _, v -> + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) { + ActivityCompat.requestPermissions(requireActivity(), arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD) + false // If permission is granted, auto-download will be enabled in onRequestPermissionsResult() + } else { + true + } + } + } // Broadcast enabled val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return @@ -204,9 +221,31 @@ class SettingsActivity : AppCompatActivity() { true } } + + fun enableAutoDownload() { + val autoDownloadPrefId = context?.getString(R.string.settings_notifications_auto_download_key) ?: return + val autoDownload: SwitchPreference? = findPreference(autoDownloadPrefId) + autoDownload?.isChecked = true + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + enableAutoDownload() + repository.setAutoDownloadEnabled(true) + } + } + } + + private fun enableAutoDownload() { + if (!this::fragment.isInitialized) return + fragment.enableAutoDownload() } companion object { - const val TAG = "NtfySettingsActivity" + private const val TAG = "NtfySettingsActivity" + private const val REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD = 2586 } } 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 e9154e4..20ce1d9 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -4,6 +4,7 @@ import android.animation.ArgbEvaluator import android.animation.ValueAnimator import android.content.Context import android.net.Uri +import android.provider.OpenableColumns import android.view.Window import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.PROGRESS_NONE @@ -110,7 +111,8 @@ fun formatTitle(notification: Notification): String { } // 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 { +fun fileExists(context: Context, uri: String?): Boolean { + if (uri == null) return false val resolver = context.applicationContext.contentResolver return try { val fileIS = resolver.openInputStream(Uri.parse(uri)) @@ -121,6 +123,24 @@ fun fileExists(context: Context, uri: String): Boolean { } } +// Queries the filename of a content URI +fun queryFilename(context: Context, contentUri: String?, fallbackName: String): String { + if (contentUri == null) { + return fallbackName + } + try { + val resolver = context.applicationContext.contentResolver + val cursor = resolver.query(Uri.parse(contentUri), null, null, null, null) ?: return fallbackName + return cursor.use { c -> + val nameIndex = c.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) + c.moveToFirst() + c.getString(nameIndex) + } + } catch (_: Exception) { + return fallbackName + } +} + // 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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d144fef..b26aa8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -115,7 +115,17 @@ Copy URL Copied URL to clipboard Cannot open or download attachment. Link expired and no local file found. - Cannot open attachment: File may have been deleted, or there is no app to open the file. + Cannot open attachment: %1$s + Cannot open attachment: File may have been deleted, or there is no app to open the file. + Attachment download failed: %1$s + not downloaded + not downloaded, link expired + not downloaded, expires %1$s + %1$d%% downloaded + deleted + deleted, link expired + deleted, link expires %1$s + download failed Notifications enabled @@ -155,6 +165,11 @@ Open Browse + Download + %1$s\nFile: %2$s + Downloading %1$s, %2$d%%\n%3$s + %1$s\nFile: %2$s, download successful + %1$s\nFile: %2$s, download failed Settings diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..42876b8 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + +