Request permissions for older versions; filename things; polishing

This commit is contained in:
Philipp Heckel 2022-01-11 17:00:18 -05:00
parent 79053c62fb
commit 1cf781b27b
15 changed files with 345 additions and 177 deletions

View file

@ -13,7 +13,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <!-- Only required on SDK <= 28 --> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/> <!-- Only required on SDK <= 28 -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/> <!-- Required to install packages downloaded through ntfy; craazyy! --> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/> <!-- Required to install packages downloaded through ntfy; craazyy! -->
<application <application
@ -95,6 +95,16 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- Broadcast receiver for the "Download" attachment action in the notification popup -->
<receiver
android:name=".msg.NotificationService$DownloadBroadcastReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="io.heckel.ntfy.DOWNLOAD_ATTACHMENT"/>
</intent-filter>
</receiver>
<!-- Firebase messaging (note that this is empty in the F-Droid flavor) --> <!-- Firebase messaging (note that this is empty in the F-Droid flavor) -->
<service <service
android:name=".firebase.FirebaseService" android:name=".firebase.FirebaseService"
@ -110,5 +120,16 @@
android:name="com.google.firebase.messaging.default_notification_icon" android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification"/> android:resource="@drawable/ic_notification"/>
<!-- FileProvider required for older Android versions (<= P), to allow passing the file URI in the open intent.
Avoids "exposed beyong app through Intent.getData" exception, see see https://stackoverflow.com/a/57288352/1440785 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
</application> </application>
</manifest> </manifest>

View file

@ -73,6 +73,7 @@ data class Attachment(
const val PROGRESS_NONE = -1 const val PROGRESS_NONE = -1
const val PROGRESS_INDETERMINATE = -2 const val PROGRESS_INDETERMINATE = -2
const val PROGRESS_FAILED = -3
const val PROGRESS_DONE = 100 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)

View file

@ -1,6 +1,7 @@
package io.heckel.ntfy.data package io.heckel.ntfy.data
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.* import androidx.lifecycle.*
@ -162,7 +163,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
} }
fun getAutoDownloadEnabled(): Boolean { 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) { fun setAutoDownloadEnabled(enabled: Boolean) {

View file

@ -2,15 +2,24 @@ package io.heckel.ntfy.msg
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.* import io.heckel.ntfy.data.*
import io.heckel.ntfy.util.queryFilename
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class AttachmentDownloadWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { 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 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() try {
.url(attachment.url) val request = Request.Builder()
.addHeader("User-Agent", ApiService.USER_AGENT) .url(attachment.url)
.build() .addHeader("User-Agent", ApiService.USER_AGENT)
client.newCall(request).execute().use { response -> .build()
if (!response.isSuccessful || response.body == null) { client.newCall(request).execute().use { response ->
throw Exception("Attachment download failed: ${response.code}") 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)
} }
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) val name = attachment.name
put(MediaStore.MediaColumns.IS_DOWNLOAD, 1) val size = attachment.size ?: 0
} val resolver = applicationContext.contentResolver
val uri = resolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), details) val uri = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
?: throw Exception("Cannot get content URI") val file = ensureSafeNewFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), name)
Log.d(TAG, "Starting download to content URI: $uri") FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file)
var bytesCopied: Long = 0 } else {
val out = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") val details = ContentValues().apply {
out.use { fileOut -> put(MediaStore.MediaColumns.DISPLAY_NAME, name)
val fileIn = response.body!!.byteStream() if (attachment.type != null) {
val buffer = ByteArray(8 * 1024) put(MediaStore.MediaColumns.MIME_TYPE, attachment.type)
var bytes = fileIn.read(buffer) }
var lastProgress = 0L put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
while (bytes >= 0) { put(MediaStore.MediaColumns.IS_DOWNLOAD, 1)
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) resolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), details)
bytesCopied += bytes ?: throw Exception("Cannot get content URI")
bytes = fileIn.read(buffer)
} }
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") } catch (e: Exception) {
val newAttachment = attachment.copy(contentUri = uri.toString(), size = bytesCopied, progress = PROGRESS_DONE) Log.w(TAG, "Attachment download failed", e)
val newAttachment = attachment.copy(progress = PROGRESS_FAILED)
val newNotification = notification.copy(attachment = newAttachment) val newNotification = notification.copy(attachment = newAttachment)
repository.updateNotification(newNotification)
notifier.update(subscription, 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 { companion object {
private const val TAG = "NtfyAttachDownload" private const val TAG = "NtfyAttachDownload"
private const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml
} }
} }

View file

@ -25,6 +25,8 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
} }
fun dispatch(subscription: Subscription, notification: Notification) { fun dispatch(subscription: Subscription, notification: Notification) {
Log.d(TAG, "Dispatching $notification for subscription $subscription")
val muted = getMuted(subscription) val muted = getMuted(subscription)
val notify = shouldNotify(subscription, notification, muted) val notify = shouldNotify(subscription, notification, muted)
val broadcast = shouldBroadcast(subscription) val broadcast = shouldBroadcast(subscription)

View file

@ -9,9 +9,12 @@ import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat 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.R
import io.heckel.ntfy.data.*
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.ui.DetailActivity import io.heckel.ntfy.ui.DetailActivity
import io.heckel.ntfy.ui.MainActivity import io.heckel.ntfy.ui.MainActivity
import io.heckel.ntfy.util.* import io.heckel.ntfy.util.*
@ -49,40 +52,25 @@ class NotificationService(val context: Context) {
private fun displayInternal(subscription: Subscription, notification: Notification, update: Boolean = false) { 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)
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)
.setColor(ContextCompat.getColor(context, R.color.primaryColor)) .setColor(ContextCompat.getColor(context, R.color.primaryColor))
.setContentTitle(title) .setContentTitle(title)
.setContentText(message)
.setOnlyAlertOnce(true) // Do not vibrate or play sound if already showing (updates!) .setOnlyAlertOnce(true) // Do not vibrate or play sound if already showing (updates!)
.setAutoCancel(true) // Cancel when notification is clicked .setAutoCancel(true) // Cancel when notification is clicked
setStyle(builder, notification, message) // Preview picture or big text style setStyleAndText(builder, notification) // Preview picture or big text style
setContentIntent(builder, subscription, notification) setClickAction(builder, subscription, notification)
maybeSetSound(builder, update) maybeSetSound(builder, update)
maybeSetProgress(builder, notification) maybeSetProgress(builder, notification)
maybeAddOpenAction(builder, notification) maybeAddOpenAction(builder, notification)
maybeAddBrowseAction(builder, notification) maybeAddBrowseAction(builder, notification)
maybeAddDownloadAction(builder, notification)
maybeCreateNotificationChannel(notification.priority) maybeCreateNotificationChannel(notification.priority)
notificationManager.notify(notification.notificationId, builder.build()) 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<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 (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) { private fun maybeSetSound(builder: NotificationCompat.Builder, update: Boolean) {
if (!update) { if (!update) {
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) 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 contentUri = notification.attachment?.contentUri
val isSupportedImage = supportedImage(notification.attachment?.type) val isSupportedImage = supportedImage(notification.attachment?.type)
if (contentUri != null && isSupportedImage) { if (contentUri != null && isSupportedImage) {
@ -101,19 +89,46 @@ class NotificationService(val context: Context) {
val bitmapStream = resolver.openInputStream(Uri.parse(contentUri)) val bitmapStream = resolver.openInputStream(Uri.parse(contentUri))
val bitmap = BitmapFactory.decodeStream(bitmapStream) val bitmap = BitmapFactory.decodeStream(bitmapStream)
builder builder
.setContentText(formatMessage(notification))
.setLargeIcon(bitmap) .setLargeIcon(bitmap)
.setStyle(NotificationCompat.BigPictureStyle() .setStyle(NotificationCompat.BigPictureStyle()
.bigPicture(bitmap) .bigPicture(bitmap)
.bigLargeIcon(null)) .bigLargeIcon(null))
} catch (_: Exception) { } catch (_: Exception) {
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) val message = formatMessageMaybeWithAttachmentInfo(notification)
builder
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
} }
} else { } 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 == "") { if (notification.click == "") {
builder.setContentIntent(detailActivityIntent(subscription)) builder.setContentIntent(detailActivityIntent(subscription))
} else { } else {
@ -140,6 +155,8 @@ class NotificationService(val context: Context) {
if (notification.attachment?.contentUri != null) { if (notification.attachment?.contentUri != null) {
val contentUri = Uri.parse(notification.attachment.contentUri) val contentUri = Uri.parse(notification.attachment.contentUri)
val intent = Intent(Intent.ACTION_VIEW, 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) val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_open), pendingIntent).build()) 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) { private fun maybeAddBrowseAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri != null) { if (notification.attachment?.contentUri != null) {
val intent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS) val intent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0) val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_browse), pendingIntent).build()) 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? { private fun detailActivityIntent(subscription: Subscription): PendingIntent? {
val intent = Intent(context, DetailActivity::class.java) val intent = Intent(context, DetailActivity::class.java)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
@ -215,6 +254,7 @@ class NotificationService(val context: Context) {
companion object { companion object {
private const val TAG = "NtfyNotifService" 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_MIN = "ntfy-min"
private const val CHANNEL_ID_LOW = "ntfy-low" private const val CHANNEL_ID_LOW = "ntfy-low"

View file

@ -3,6 +3,7 @@ package io.heckel.ntfy.service
import android.util.Log import android.util.Log
import io.heckel.ntfy.data.ConnectionState import io.heckel.ntfy.data.ConnectionState
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.topicUrl import io.heckel.ntfy.util.topicUrl
@ -11,15 +12,17 @@ import okhttp3.Call
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
class SubscriberConnection( class SubscriberConnection(
private val repository: Repository,
private val api: ApiService, private val api: ApiService,
private val baseUrl: String, private val baseUrl: String,
private val sinceTime: Long, private val sinceTime: Long,
private val subscriptions: Map<Long, Subscription>, private val topicsToSubscriptionIds: Map<String, Long>, // Topic -> Subscription ID
private val stateChangeListener: (Collection<Subscription>, ConnectionState) -> Unit, private val stateChangeListener: (Collection<Long>, ConnectionState) -> Unit,
private val notificationListener: (Subscription, Notification) -> Unit, private val notificationListener: (Subscription, Notification) -> Unit,
private val serviceActive: () -> Boolean 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 val url = topicUrl(baseUrl, topicsStr)
private var since: Long = sinceTime private var since: Long = sinceTime
@ -28,16 +31,17 @@ class SubscriberConnection(
fun start(scope: CoroutineScope) { fun start(scope: CoroutineScope) {
job = scope.launch(Dispatchers.IO) { 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 // Retry-loop: if the connection fails, we retry unless the job or service is cancelled/stopped
var retryMillis = 0L var retryMillis = 0L
while (isActive && serviceActive()) { 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 startTime = System.currentTimeMillis()
val notify = { topic: String, notification: Notification -> val notify = notify@ { topic: String, notification: Notification ->
since = notification.timestamp 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) val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id)
notificationListener(subscription, notificationWithSubscriptionId) notificationListener(subscription, notificationWithSubscriptionId)
} }
@ -45,7 +49,7 @@ class SubscriberConnection(
val fail = { e: Exception -> val fail = { e: Exception ->
failed.set(true) failed.set(true)
if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection 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 { try {
call = api.subscribe(baseUrl, topicsStr, since, notify, fail) call = api.subscribe(baseUrl, topicsStr, since, notify, fail)
while (!failed.get() && !call.isCanceled() && isActive && serviceActive()) { 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()}") 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 delay(CONNECTION_LOOP_DELAY_MILLIS) // Resumes immediately if job is cancelled
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "[$url] Connection failed: ${e.message}", e) Log.e(TAG, "[$url] Connection failed: ${e.message}", e)
if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection 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<Long, Subscription>): Boolean {
return subscriptions.keys == otherSubscriptions.keys
}
fun since(): Long { fun since(): Long {
return since return since
} }
@ -91,6 +91,10 @@ class SubscriberConnection(
if (this::call.isInitialized) call?.cancel() if (this::call.isInitialized) call?.cancel()
} }
fun matches(otherSubscriptionIds: Collection<Long>): Boolean {
return subscriptionIds.toSet() == otherSubscriptionIds.toSet()
}
private fun nextRetryMillis(retryMillis: Long, startTime: Long): Long { private fun nextRetryMillis(retryMillis: Long, startTime: Long): Long {
val connectionDurationMillis = System.currentTimeMillis() - startTime val connectionDurationMillis = System.currentTimeMillis() - startTime
if (connectionDurationMillis > RETRY_RESET_AFTER_MILLIS) { if (connectionDurationMillis > RETRY_RESET_AFTER_MILLIS) {

View file

@ -11,8 +11,6 @@ import android.os.SystemClock
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
@ -140,37 +138,38 @@ class SubscriberService : Service() {
private fun refreshConnections() = private fun refreshConnections() =
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
// Group subscriptions by base URL (Base URL -> Map<SubId -> Sub>. // Group INSTANT subscriptions by base URL, there is only one connection per base URL
// There is only one connection per base URL. val instantSubscriptions = repository.getSubscriptions()
val subscriptions = repository.getSubscriptions()
.filter { s -> s.instant } .filter { s -> s.instant }
val subscriptionsByBaseUrl = subscriptions val instantSubscriptionsByBaseUrl = instantSubscriptions // BaseUrl->Map[Topic->SubscriptionId]
.groupBy { s -> s.baseUrl } .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, "Refreshing subscriptions")
Log.d(TAG, "- Subscriptions: $subscriptionsByBaseUrl") Log.d(TAG, "- Subscriptions: $instantSubscriptionsByBaseUrl")
Log.d(TAG, "- Active connections: $connections") Log.d(TAG, "- Active connections: $connections")
// Start new connections and restart connections (if subscriptions have changed) // Start new connections and restart connections (if subscriptions have changed)
subscriptionsByBaseUrl.forEach { (baseUrl, subscriptions) -> instantSubscriptionsByBaseUrl.forEach { (baseUrl, subscriptions) ->
val connection = connections[baseUrl] val connection = connections[baseUrl]
var since = 0L var since = 0L
if (connection != null && !connection.matches(subscriptions)) { if (connection != null && !connection.matches(subscriptions.values)) {
since = connection.since() since = connection.since()
connections.remove(baseUrl) connections.remove(baseUrl)
connection.cancel() connection.cancel()
} }
if (!connections.containsKey(baseUrl)) { if (!connections.containsKey(baseUrl)) {
val serviceActive = { -> isServiceStarted } 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 connections[baseUrl] = connection
connection.start(this) connection.start(this)
} }
} }
// Close connections without subscriptions // Close connections without subscriptions
val baseUrls = subscriptionsByBaseUrl.keys val baseUrls = instantSubscriptionsByBaseUrl.keys
connections.keys().toList().forEach { baseUrl -> connections.keys().toList().forEach { baseUrl ->
if (!baseUrls.contains(baseUrl)) { if (!baseUrls.contains(baseUrl)) {
val connection = connections.remove(baseUrl) val connection = connections.remove(baseUrl)
@ -182,12 +181,12 @@ class SubscriberService : Service() {
if (connections.size > 0) { if (connections.size > 0) {
synchronized(this) { synchronized(this) {
val title = getString(R.string.channel_subscriber_notification_title) 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) 1 -> getString(R.string.channel_subscriber_notification_text_one)
2 -> getString(R.string.channel_subscriber_notification_text_two) 2 -> getString(R.string.channel_subscriber_notification_text_two)
3 -> getString(R.string.channel_subscriber_notification_text_three) 3 -> getString(R.string.channel_subscriber_notification_text_three)
4 -> getString(R.string.channel_subscriber_notification_text_four) 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) serviceNotification = createNotification(title, text)
notificationManager?.notify(NOTIFICATION_SERVICE_ID, serviceNotification) notificationManager?.notify(NOTIFICATION_SERVICE_ID, serviceNotification)
@ -195,8 +194,7 @@ class SubscriberService : Service() {
} }
} }
private fun onStateChanged(subscriptions: Collection<Subscription>, state: ConnectionState) { private fun onStateChanged(subscriptionIds: Collection<Long>, state: ConnectionState) {
val subscriptionIds = subscriptions.map { it.id }
repository.updateState(subscriptionIds, state) repository.updateState(subscriptionIds, state)
} }

View file

@ -111,7 +111,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
val onNotificationClick = { n: Notification -> onNotificationClick(n) } val onNotificationClick = { n: Notification -> onNotificationClick(n) }
val onNotificationLongClick = { n: Notification -> onNotificationLongClick(n) } val onNotificationLongClick = { n: Notification -> onNotificationLongClick(n) }
adapter = DetailAdapter(onNotificationClick, onNotificationLongClick) adapter = DetailAdapter(this, onNotificationClick, onNotificationLongClick)
mainList = findViewById(R.id.detail_notification_list) mainList = findViewById(R.id.detail_notification_list)
mainList.adapter = adapter mainList.adapter = adapter
@ -298,6 +298,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) { override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
Log.d(TAG, "Setting subscription 'muted until' to $mutedUntilTimestamp")
val subscription = repository.getSubscription(subscriptionId) val subscription = repository.getSubscription(subscriptionId)
val newSubscription = subscription?.copy(mutedUntil = mutedUntilTimestamp) val newSubscription = subscription?.copy(mutedUntil = mutedUntilTimestamp)
newSubscription?.let { repository.updateSubscription(newSubscription) } newSubscription?.let { repository.updateSubscription(newSubscription) }

View file

@ -1,16 +1,20 @@
package io.heckel.ntfy.ui package io.heckel.ntfy.ui
import android.Manifest
import android.app.Activity
import android.app.DownloadManager import android.app.DownloadManager
import android.content.* import android.content.*
import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.os.Build
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.* import android.widget.*
import androidx.core.app.ActivityCompat
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
@ -20,16 +24,12 @@ import androidx.work.WorkManager
import androidx.work.workDataOf import androidx.work.workDataOf
import com.stfalcon.imageviewer.StfalconImageViewer import com.stfalcon.imageviewer.StfalconImageViewer
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.data.Attachment import io.heckel.ntfy.data.*
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.PROGRESS_DONE
import io.heckel.ntfy.data.PROGRESS_NONE
import io.heckel.ntfy.msg.AttachmentDownloadWorker import io.heckel.ntfy.msg.AttachmentDownloadWorker
import io.heckel.ntfy.util.* import io.heckel.ntfy.util.*
import java.util.* import java.util.*
class DetailAdapter(private val activity: Activity, 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
@ -37,7 +37,7 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder {
val view = LayoutInflater.from(parent.context) val view = LayoutInflater.from(parent.context)
.inflate(R.layout.fragment_detail_item, parent, false) .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. */ /* 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. */ /* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
class DetailViewHolder(itemView: View, private val selected: Set<String>, val onClick: (Notification) -> Unit, val onLongClick: (Notification) -> Unit) : class DetailViewHolder(private val activity: Activity, itemView: View, private val selected: Set<String>, val onClick: (Notification) -> Unit, val onLongClick: (Notification) -> Unit) :
RecyclerView.ViewHolder(itemView) { RecyclerView.ViewHolder(itemView) {
private var notification: Notification? = null private var notification: Notification? = null
private val priorityImageView: ImageView = itemView.findViewById(R.id.detail_item_priority_image) 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) { if (attachment.contentUri != null) {
openItem.setOnMenuItemClickListener { openItem.setOnMenuItemClickListener {
try { 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) { } catch (e: ActivityNotFoundException) {
Toast 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() .show()
} catch (_: Exception) {
// URI parse exception and others; we don't care!
} }
true true
} }
} }
browseItem.setOnMenuItemClickListener { 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 true
} }
copyUrlItem.setOnMenuItemClickListener { copyUrlItem.setOnMenuItemClickListener {
@ -214,6 +222,11 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
true true
} }
downloadItem.setOnMenuItemClickListener { 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) scheduleAttachmentDownload(context, notification)
true 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 { 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 notYetDownloaded = !exists && attachment.progress == PROGRESS_NONE
val downloading = !exists && attachment.progress in 0..99 val downloading = !exists && attachment.progress in 0..99
val deleted = !exists && attachment.progress == PROGRESS_DONE 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 expired = attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000
val expires = attachment.expires != null && attachment.expires > System.currentTimeMillis()/1000 val expires = attachment.expires != null && attachment.expires > System.currentTimeMillis()/1000
val infos = mutableListOf<String>() val infos = mutableListOf<String>()
@ -241,22 +255,24 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
} }
if (notYetDownloaded) { if (notYetDownloaded) {
if (expired) { if (expired) {
infos.add("not downloaded, link expired") infos.add(context.getString(R.string.detail_item_download_info_not_downloaded_expired))
} else if (expires) { } 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 { } else {
infos.add("not downloaded") infos.add(context.getString(R.string.detail_item_download_info_not_downloaded))
} }
} else if (downloading) { } 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) { } else if (deleted) {
if (expired) { if (expired) {
infos.add("deleted, link expired") infos.add(context.getString(R.string.detail_item_download_info_deleted_expired))
} else if (expires) { } 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 { } 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) { return if (infos.size > 0) {
"$name\n${infos.joinToString(", ")}" "$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) { private fun maybeRenderAttachmentImage(context: Context, attachment: Attachment, image: Boolean) {
if (!image) { if (!image) {
attachmentImageView.visibility = View.GONE attachmentImageView.visibility = View.GONE
@ -328,5 +327,6 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
companion object { companion object {
const val TAG = "NtfyDetailAdapter" const val TAG = "NtfyDetailAdapter"
const val REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD = 9876
} }
} }

View file

@ -117,44 +117,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
// Background things // Background things
startPeriodicPollWorker() startPeriodicPollWorker()
startPeriodicServiceRestartWorker() 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<String>,
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() { override fun onResume() {
super.onResume() super.onResume()
showHideNotificationMenuItems() showHideNotificationMenuItems()

View file

@ -1,13 +1,18 @@
package io.heckel.ntfy.ui package io.heckel.ntfy.ui
import android.Manifest
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.preference.* import androidx.preference.*
import androidx.preference.Preference.OnPreferenceClickListener import androidx.preference.Preference.OnPreferenceClickListener
@ -20,6 +25,7 @@ import io.heckel.ntfy.util.toPriorityString
class SettingsActivity : AppCompatActivity() { class SettingsActivity : AppCompatActivity() {
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
private lateinit var fragment: SettingsFragment
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -28,9 +34,10 @@ class SettingsActivity : AppCompatActivity() {
Log.d(TAG, "Create $this") Log.d(TAG, "Create $this")
if (savedInstanceState == null) { if (savedInstanceState == null) {
fragment = SettingsFragment(repository, supportFragmentManager)
supportFragmentManager supportFragmentManager
.beginTransaction() .beginTransaction()
.replace(R.id.settings_layout, SettingsFragment(repository, supportFragmentManager)) .replace(R.id.settings_layout, fragment)
.commit() .commit()
} }
@ -125,6 +132,16 @@ class SettingsActivity : AppCompatActivity() {
getString(R.string.settings_notifications_auto_download_summary_off) 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 // Broadcast enabled
val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return
@ -204,9 +221,31 @@ class SettingsActivity : AppCompatActivity() {
true 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<String>, 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 { companion object {
const val TAG = "NtfySettingsActivity" private const val TAG = "NtfySettingsActivity"
private const val REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD = 2586
} }
} }

View file

@ -4,6 +4,7 @@ import android.animation.ArgbEvaluator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns
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.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 // 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 val resolver = context.applicationContext.contentResolver
return try { return try {
val fileIS = resolver.openInputStream(Uri.parse(uri)) 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 // 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

@ -115,7 +115,17 @@
<string name="detail_item_menu_copy_url">Copy URL</string> <string name="detail_item_menu_copy_url">Copy URL</string>
<string name="detail_item_menu_copy_url_copied">Copied URL to clipboard</string> <string name="detail_item_menu_copy_url_copied">Copied URL to clipboard</string>
<string name="detail_item_cannot_download">Cannot open or download attachment. Link expired and no local file found.</string> <string name="detail_item_cannot_download">Cannot open or download attachment. Link expired and no local file found.</string>
<string name="detail_item_cannot_open">Cannot open attachment: File may have been deleted, or there is no app to open the file.</string> <string name="detail_item_cannot_open">Cannot open attachment: %1$s</string>
<string name="detail_item_cannot_open_not_found">Cannot open attachment: File may have been deleted, or there is no app to open the file.</string>
<string name="detail_item_download_failed">Attachment download failed: %1$s</string>
<string name="detail_item_download_info_not_downloaded">not downloaded</string>
<string name="detail_item_download_info_not_downloaded_expired">not downloaded, link expired</string>
<string name="detail_item_download_info_not_downloaded_expires_x">not downloaded, expires %1$s</string>
<string name="detail_item_download_info_downloading_x_percent">%1$d%% downloaded</string>
<string name="detail_item_download_info_deleted">deleted</string>
<string name="detail_item_download_info_deleted_expired">deleted, link expired</string>
<string name="detail_item_download_info_deleted_expires_x">deleted, link expires %1$s</string>
<string name="detail_item_download_info_download_failed">download failed</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>
@ -155,6 +165,11 @@
<!-- Notification popup --> <!-- Notification popup -->
<string name="notification_popup_action_open">Open</string> <string name="notification_popup_action_open">Open</string>
<string name="notification_popup_action_browse">Browse</string> <string name="notification_popup_action_browse">Browse</string>
<string name="notification_popup_action_download">Download</string>
<string name="notification_popup_file">%1$s\nFile: %2$s</string>
<string name="notification_popup_file_downloading">Downloading %1$s, %2$d%%\n%3$s</string>
<string name="notification_popup_file_download_successful">%1$s\nFile: %2$s, download successful</string>
<string name="notification_popup_file_download_failed">%1$s\nFile: %2$s, download failed</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" ?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="external_files" path="."/>
</paths>