Use Content-Type and Content-Length response headers

This commit is contained in:
Philipp Heckel 2022-01-14 12:32:36 -05:00
parent 3b9f3f2e52
commit 0fce663975
6 changed files with 137 additions and 65 deletions

View file

@ -59,7 +59,7 @@ data class Notification(
@Entity @Entity
data class Attachment( data class Attachment(
@ColumnInfo(name = "name") val name: String, // Filename (mandatory, see ntfy server) @ColumnInfo(name = "name") val name: String, // Filename
@ColumnInfo(name = "type") val type: String?, // MIME type @ColumnInfo(name = "type") val type: String?, // MIME type
@ColumnInfo(name = "size") val size: Long?, // Size in bytes @ColumnInfo(name = "size") val size: Long?, // Size in bytes
@ColumnInfo(name = "expires") val expires: Long?, // Unix timestamp @ColumnInfo(name = "expires") val expires: Long?, // Unix timestamp

View file

@ -18,12 +18,15 @@ class DownloadManager {
private const val TAG = "NtfyDownloadManager" private const val TAG = "NtfyDownloadManager"
private const val DOWNLOAD_WORK_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_" private const val DOWNLOAD_WORK_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_"
fun enqueue(context: Context, id: String) { fun enqueue(context: Context, id: String, userAction: Boolean) {
val workManager = WorkManager.getInstance(context) val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_NAME_PREFIX + id val workName = DOWNLOAD_WORK_NAME_PREFIX + id
Log.d(TAG,"Enqueuing work to download attachment for notification $id, work: $workName") Log.d(TAG,"Enqueuing work to download attachment for notification $id, work: $workName")
val workRequest = OneTimeWorkRequest.Builder(DownloadWorker::class.java) val workRequest = OneTimeWorkRequest.Builder(DownloadWorker::class.java)
.setInputData(workDataOf("id" to id)) .setInputData(workDataOf(
DownloadWorker.INPUT_DATA_ID to id,
DownloadWorker.INPUT_DATA_USER_ACTION to userAction
))
.build() .build()
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest) workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
} }

View file

@ -9,6 +9,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import android.webkit.MimeTypeMap
import android.widget.Toast import android.widget.Toast
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.work.Worker import androidx.work.Worker
@ -20,40 +21,48 @@ import io.heckel.ntfy.data.*
import io.heckel.ntfy.util.queryFilename import io.heckel.ntfy.util.queryFilename
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class DownloadWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { class DownloadWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.callTimeout(5, TimeUnit.MINUTES) // Total timeout for entire request .callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS)
.build() .build()
private val notifier = NotificationService(context) private val notifier = NotificationService(context)
private lateinit var repository: Repository
private lateinit var subscription: Subscription
private lateinit var notification: Notification
private lateinit var attachment: Attachment
private var uri: Uri? = null private var uri: Uri? = null
override fun doWork(): Result { override fun doWork(): Result {
if (context.applicationContext !is Application) return Result.failure() if (context.applicationContext !is Application) return Result.failure()
val notificationId = inputData.getString("id") ?: return Result.failure() val notificationId = inputData.getString(INPUT_DATA_ID) ?: return Result.failure()
val userAction = inputData.getBoolean(INPUT_DATA_USER_ACTION, false)
val app = context.applicationContext as Application val app = context.applicationContext as Application
val repository = app.repository repository = app.repository
val notification = repository.getNotification(notificationId) ?: return Result.failure() notification = repository.getNotification(notificationId) ?: return Result.failure()
val subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure() subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
downloadAttachment(repository, subscription, notification) attachment = notification.attachment ?: return Result.failure()
try {
downloadAttachment(userAction)
} catch (e: Exception) {
failed(e)
}
return Result.success() return Result.success()
} }
override fun onStopped() { override fun onStopped() {
val uriCopy = uri Log.d(TAG, "Attachment download was canceled")
if (uriCopy != null) { maybeDeleteFile()
val resolver = applicationContext.contentResolver
resolver.delete(uriCopy, null, null)
}
} }
private fun downloadAttachment(repository: Repository, subscription: Subscription, notification: Notification) { private fun downloadAttachment(userAction: Boolean) {
val attachment = notification.attachment ?: return
Log.d(TAG, "Downloading attachment from ${attachment.url}") Log.d(TAG, "Downloading attachment from ${attachment.url}")
try { try {
@ -63,77 +72,80 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
throw Exception("Attachment download failed: ${response.code}") throw Exception("Unexpected response: ${response.code}")
}
save(updateAttachmentFromResponse(response))
if (!userAction && shouldAbortDownload()) {
Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting")
return
} }
val name = attachment.name
val size = attachment.size ?: 0
val resolver = applicationContext.contentResolver val resolver = applicationContext.contentResolver
val uri = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { val values = ContentValues().apply {
val file = ensureSafeNewFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), name) put(MediaStore.MediaColumns.DISPLAY_NAME, attachment.name)
FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file) if (attachment.type != null) {
} else { put(MediaStore.MediaColumns.MIME_TYPE, attachment.type)
val details = ContentValues().apply { }
put(MediaStore.MediaColumns.DISPLAY_NAME, name) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (attachment.type != null) {
put(MediaStore.MediaColumns.MIME_TYPE, attachment.type)
}
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
put(MediaStore.MediaColumns.IS_DOWNLOAD, 1) put(MediaStore.MediaColumns.IS_DOWNLOAD, 1)
put(MediaStore.MediaColumns.IS_PENDING, 1) // While downloading
} }
resolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), details) }
?: throw Exception("Cannot get content URI") val uri = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
val file = ensureSafeNewFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), attachment.name)
FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file)
} else {
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
resolver.insert(contentUri, values) ?: throw Exception("Cannot insert content")
} }
this.uri = uri // Required for cleanup in onStopped() this.uri = uri // Required for cleanup in onStopped()
Log.d(TAG, "Starting download to content URI: $uri") Log.d(TAG, "Starting download to content URI: $uri")
val contentLength = response.headers["Content-Length"]?.toLongOrNull()
var bytesCopied: Long = 0 var bytesCopied: Long = 0
val out = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
out.use { fileOut -> outFile.use { fileOut ->
val fileIn = response.body!!.byteStream() val fileIn = response.body!!.byteStream()
val buffer = ByteArray(BUFFER_SIZE) val buffer = ByteArray(BUFFER_SIZE)
var bytes = fileIn.read(buffer) var bytes = fileIn.read(buffer)
var lastProgress = 0L var lastProgress = 0L
while (bytes >= 0) { while (bytes >= 0) {
if (System.currentTimeMillis() - lastProgress > NOTIFICATION_UPDATE_INTERVAL_MILLIS) { if (System.currentTimeMillis() - lastProgress > NOTIFICATION_UPDATE_INTERVAL_MILLIS) {
if (isStopped) { if (isStopped) { // Canceled by user
Log.d(TAG, "Attachment download was canceled") save(attachment.copy(progress = PROGRESS_NONE))
val newAttachment = attachment.copy(progress = PROGRESS_NONE)
val newNotification = notification.copy(attachment = newAttachment)
notifier.update(subscription, newNotification)
repository.updateNotification(newNotification)
return // File will be deleted in onStopped() return // File will be deleted in onStopped()
} }
val progress = if (size > 0) (bytesCopied.toFloat()/size.toFloat()*100).toInt() else PROGRESS_INDETERMINATE val progress = if (attachment.size != null && attachment.size!! > 0) {
val newAttachment = attachment.copy(progress = progress) (bytesCopied.toFloat()/attachment.size!!.toFloat()*100).toInt()
val newNotification = notification.copy(attachment = newAttachment) } else {
notifier.update(subscription, newNotification) PROGRESS_INDETERMINATE
repository.updateNotification(newNotification) }
save(attachment.copy(progress = progress))
lastProgress = System.currentTimeMillis() lastProgress = System.currentTimeMillis()
} }
if (contentLength != null && bytesCopied > contentLength) {
throw Exception("Attachment is longer than response headers said.")
}
fileOut.write(buffer, 0, bytes) fileOut.write(buffer, 0, bytes)
bytesCopied += bytes bytesCopied += bytes
bytes = fileIn.read(buffer) bytes = fileIn.read(buffer)
} }
} }
Log.d(TAG, "Attachment download: successful response, proceeding with download") Log.d(TAG, "Attachment download: successful response, proceeding with download")
val actualName = queryFilename(context, uri.toString(), name) val actualName = queryFilename(context, uri.toString(), attachment.name)
val newAttachment = attachment.copy( save(attachment.copy(
name = actualName, name = actualName,
size = bytesCopied, size = bytesCopied,
contentUri = uri.toString(), contentUri = uri.toString(),
progress = PROGRESS_DONE progress = PROGRESS_DONE
) ))
val newNotification = notification.copy(attachment = newAttachment) if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
repository.updateNotification(newNotification) values.put(MediaStore.MediaColumns.IS_PENDING, 0)
notifier.update(subscription, newNotification) resolver.update(uri, values, null, null)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Attachment download failed", e) failed(e)
// Mark attachment download as failed
val newAttachment = attachment.copy(progress = PROGRESS_FAILED)
val newNotification = notification.copy(attachment = newAttachment)
notifier.update(subscription, newNotification)
repository.updateNotification(newNotification)
// Toast in a Worker: https://stackoverflow.com/a/56428145/1440785 // Toast in a Worker: https://stackoverflow.com/a/56428145/1440785
val handler = Handler(Looper.getMainLooper()) val handler = Handler(Looper.getMainLooper())
@ -145,6 +157,64 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
} }
} }
private fun updateAttachmentFromResponse(response: Response): Attachment {
val size = if (response.headers["Content-Length"]?.toLongOrNull() != null) {
response.headers["Content-Length"]?.toLong()
} else {
attachment.size // May be null!
}
val mimeType = if (response.headers["Content-Type"] != null) {
response.headers["Content-Type"]
} else {
val ext = MimeTypeMap.getFileExtensionFromUrl(attachment.url)
if (ext != null) {
val typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
typeFromExt ?: attachment.type // May be null!
} else {
attachment.type // May be null!
}
}
return attachment.copy(
size = size,
type = mimeType
)
}
private fun failed(e: Exception) {
Log.w(TAG, "Attachment download failed", e)
save(attachment.copy(progress = PROGRESS_FAILED))
maybeDeleteFile()
}
private fun maybeDeleteFile() {
val uriCopy = uri
if (uriCopy != null) {
Log.d(TAG, "Deleting leftover attachment $uriCopy")
val resolver = applicationContext.contentResolver
resolver.delete(uriCopy, null, null)
}
}
private fun save(newAttachment: Attachment) {
Log.d(TAG, "Updating attachment: $newAttachment")
attachment = newAttachment
notification = notification.copy(attachment = newAttachment)
notifier.update(subscription, notification)
repository.updateNotification(notification)
}
private fun shouldAbortDownload(): Boolean {
val maxAutoDownloadSize = repository.getAutoDownloadMaxSize()
when (maxAutoDownloadSize) {
Repository.AUTO_DOWNLOAD_NEVER -> return true
Repository.AUTO_DOWNLOAD_ALWAYS -> return false
else -> {
val size = attachment.size ?: return true // Abort if size unknown
return size > maxAutoDownloadSize
}
}
}
private fun ensureSafeNewFile(dir: File, name: String): File { private fun ensureSafeNewFile(dir: File, name: String): File {
val safeName = name.replace("[^-_.()\\w]+".toRegex(), "_"); val safeName = name.replace("[^-_.()\\w]+".toRegex(), "_");
val file = File(dir, safeName) val file = File(dir, safeName)
@ -165,6 +235,9 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
} }
companion object { companion object {
const val INPUT_DATA_ID = "id"
const val INPUT_DATA_USER_ACTION = "userAction"
private const val TAG = "NtfyAttachDownload" private const val TAG = "NtfyAttachDownload"
private const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml private const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml
private const val BUFFER_SIZE = 8 * 1024 private const val BUFFER_SIZE = 8 * 1024

View file

@ -41,7 +41,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
} }
} }
if (download) { if (download) {
DownloadManager.enqueue(context, notification.id) DownloadManager.enqueue(context, notification.id, userAction = false)
} }
} }
@ -55,7 +55,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
Repository.AUTO_DOWNLOAD_NEVER -> return false Repository.AUTO_DOWNLOAD_NEVER -> return false
else -> { else -> {
if (notification.attachment.size == null) { if (notification.attachment.size == null) {
return false return true // DownloadWorker will bail out if attachment is too large!
} }
return notification.attachment.size <= maxAutoDownloadSize return notification.attachment.size <= maxAutoDownloadSize
} }

View file

@ -199,7 +199,7 @@ class NotificationService(val context: Context) {
val id = intent.getStringExtra("id") ?: return val id = intent.getStringExtra("id") ?: return
val action = intent.getStringExtra("action") ?: return val action = intent.getStringExtra("action") ?: return
when (action) { when (action) {
DOWNLOAD_ACTION_START -> DownloadManager.enqueue(context, id) DOWNLOAD_ACTION_START -> DownloadManager.enqueue(context, id, userAction = true)
DOWNLOAD_ACTION_CANCEL -> DownloadManager.cancel(context, id) DOWNLOAD_ACTION_CANCEL -> DownloadManager.cancel(context, id)
} }
} }
@ -236,7 +236,7 @@ class NotificationService(val context: Context) {
channel channel
} }
5 -> { 5 -> {
val channel = NotificationChannel(CHANNEL_ID_MAX, context.getString(R.string.channel_notifications_max_name), NotificationManager.IMPORTANCE_MAX) val channel = NotificationChannel(CHANNEL_ID_MAX, context.getString(R.string.channel_notifications_max_name), NotificationManager.IMPORTANCE_HIGH) // IMPORTANCE_MAX does not exist
channel.enableLights(true) channel.enableLights(true)
channel.enableVibration(true) channel.enableVibration(true)
channel.vibrationPattern = longArrayOf( channel.vibrationPattern = longArrayOf(

View file

@ -18,14 +18,10 @@ import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.stfalcon.imageviewer.StfalconImageViewer import com.stfalcon.imageviewer.StfalconImageViewer
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.data.* import io.heckel.ntfy.data.*
import io.heckel.ntfy.msg.DownloadManager import io.heckel.ntfy.msg.DownloadManager
import io.heckel.ntfy.msg.DownloadWorker
import io.heckel.ntfy.util.* import io.heckel.ntfy.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@ -254,7 +250,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD) ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD)
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
DownloadManager.enqueue(context, notification.id) DownloadManager.enqueue(context, notification.id, userAction = true)
true true
} }
cancelItem.setOnMenuItemClickListener { cancelItem.setOnMenuItemClickListener {