ntfy-android/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt
2022-09-06 21:42:40 -06:00

219 lines
9 KiB
Kotlin

package io.heckel.ntfy.msg
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.webkit.MimeTypeMap
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.db.*
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.ensureSafeNewFile
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.File
import java.util.concurrent.TimeUnit
class DownloadAttachmentWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
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
override fun doWork(): Result {
if (context.applicationContext !is Application) 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
repository = app.repository
notification = repository.getNotification(notificationId) ?: return Result.failure()
subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
attachment = notification.attachment ?: return Result.failure()
try {
downloadAttachment(userAction)
} catch (e: Exception) {
failed(e)
}
return Result.success()
}
override fun onStopped() {
Log.d(TAG, "Attachment download was canceled")
maybeDeleteFile()
}
private fun downloadAttachment(userAction: Boolean) {
Log.d(TAG, "Downloading attachment from ${attachment.url}")
try {
val request = Request.Builder()
.url(attachment.url)
.addHeader("User-Agent", ApiService.USER_AGENT)
.build()
client.newCall(request).execute().use { response ->
Log.d(TAG, "Download: headers received: $response")
if (!response.isSuccessful || response.body == null) {
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 resolver = applicationContext.contentResolver
val uri = createUri(notification)
this.uri = uri // Required for cleanup in onStopped()
Log.d(TAG, "Starting download to content URI: $uri")
var bytesCopied: Long = 0
val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
val downloadLimit = getDownloadLimit(userAction)
outFile.use { fileOut ->
val fileIn = response.body!!.byteStream()
val buffer = ByteArray(BUFFER_SIZE)
var bytes = fileIn.read(buffer)
var lastProgress = 0L
while (bytes >= 0) {
if (System.currentTimeMillis() - lastProgress > NOTIFICATION_UPDATE_INTERVAL_MILLIS) {
if (isStopped) { // Canceled by user
save(attachment.copy(progress = ATTACHMENT_PROGRESS_NONE))
return // File will be deleted in onStopped()
}
val progress = if (attachment.size != null && attachment.size!! > 0) {
(bytesCopied.toFloat()/attachment.size!!.toFloat()*100).toInt()
} else {
ATTACHMENT_PROGRESS_INDETERMINATE
}
save(attachment.copy(progress = progress))
lastProgress = System.currentTimeMillis()
}
if (downloadLimit != null && bytesCopied > downloadLimit) {
throw Exception("Attachment is longer than max download size.")
}
fileOut.write(buffer, 0, bytes)
bytesCopied += bytes
bytes = fileIn.read(buffer)
}
}
Log.d(TAG, "Attachment download: successful response, proceeding with download")
save(attachment.copy(
size = bytesCopied,
contentUri = uri.toString(),
progress = ATTACHMENT_PROGRESS_DONE
))
}
} catch (e: Exception) {
failed(e)
// 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 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 = ATTACHMENT_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 false // Don't abort if size unknown
return size > maxAutoDownloadSize
}
}
}
private fun getDownloadLimit(userAction: Boolean): Long? {
return if (userAction || repository.getAutoDownloadMaxSize() == Repository.AUTO_DOWNLOAD_ALWAYS) {
null
} else {
repository.getAutoDownloadMaxSize()
}
}
private fun createUri(notification: Notification): Uri {
val attachmentDir = File(context.cacheDir, ATTACHMENT_CACHE_DIR)
if (!attachmentDir.exists() && !attachmentDir.mkdirs()) {
throw Exception("Cannot create cache directory for attachments: $attachmentDir")
}
val file = ensureSafeNewFile(attachmentDir, notification.id)
return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file)
}
companion object {
const val INPUT_DATA_ID = "id"
const val INPUT_DATA_USER_ACTION = "userAction"
const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml
private const val TAG = "NtfyAttachDownload"
private const val ATTACHMENT_CACHE_DIR = "attachments"
private const val BUFFER_SIZE = 8 * 1024
private const val NOTIFICATION_UPDATE_INTERVAL_MILLIS = 800
}
}