BarInsta/app/src/main/java/awais/instagrabber/workers/DownloadWorker.kt

431 lines
18 KiB
Kotlin

package awais.instagrabber.workers
import android.app.Notification
import android.app.PendingIntent
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.documentfile.provider.DocumentFile
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import awais.instagrabber.BuildConfig
import awais.instagrabber.R
import awais.instagrabber.services.DeleteImageIntentService
import awais.instagrabber.utils.BitmapUtils
import awais.instagrabber.utils.Constants.DOWNLOAD_CHANNEL_ID
import awais.instagrabber.utils.Constants.NOTIF_GROUP_NAME
import awais.instagrabber.utils.DownloadUtils
import awais.instagrabber.utils.TextUtils.isEmpty
import awais.instagrabber.utils.Utils
import awais.instagrabber.utils.extensions.TAG
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.apache.commons.imaging.formats.jpeg.iptc.JpegIptcRewriter
import java.io.BufferedInputStream
import java.io.File
import java.net.URL
import java.util.*
import java.util.concurrent.ExecutionException
import java.util.stream.Collectors
import kotlin.collections.Map
import kotlin.math.abs
class DownloadWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
private val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(context)
override suspend fun doWork(): Result {
val downloadRequestFilePath = inputData.getString(KEY_DOWNLOAD_REQUEST_JSON)
if (downloadRequestFilePath.isNullOrBlank()) {
return Result.failure(Data.Builder()
.putString("error", "downloadRequest is empty or null")
.build())
}
val downloadRequestString: String
val requestFile = Uri.parse(downloadRequestFilePath)
val context = applicationContext
val contentResolver = context.contentResolver ?: return Result.failure(Data.Builder()
.putString("error", "contentResolver is null")
.build())
try {
val scanner = Scanner(contentResolver.openInputStream(requestFile))
downloadRequestString = scanner.useDelimiter("\\A").next()
} catch (e: Exception) {
Log.e(TAG, "doWork: ", e)
return Result.failure(Data.Builder()
.putString("error", e.localizedMessage)
.build())
}
if (downloadRequestString.isBlank()) {
return Result.failure(Data.Builder()
.putString("error", "downloadRequest is empty")
.build())
}
val downloadRequest: DownloadRequest = try {
Gson().fromJson(downloadRequestString, DownloadRequest::class.java)
} catch (e: JsonSyntaxException) {
Log.e(TAG, "doWork", e)
return Result.failure(Data.Builder()
.putString("error", e.localizedMessage)
.build())
} ?: return Result.failure(Data.Builder()
.putString("error", "downloadRequest is null")
.build())
val urlToFilePathMap = downloadRequest.urlToFilePathMap
download(urlToFilePathMap)
Handler(Looper.getMainLooper()).postDelayed({ showSummary(urlToFilePathMap) }, 500)
val deleted = DocumentFile.fromSingleUri(context, requestFile)!!.delete()
if (!deleted) {
Log.w(TAG, "doWork: requestFile not deleted!")
}
return Result.success()
}
private suspend fun download(urlToFilePathMap: Map<String, String>) {
val notificationId = notificationId
val entries = urlToFilePathMap.entries
var count = 1
val total = urlToFilePathMap.size
for ((url, uriString) in entries) {
updateDownloadProgress(notificationId, count, total, 0f)
withContext(Dispatchers.IO) {
val file = DocumentFile.fromSingleUri(applicationContext, Uri.parse(uriString))
download(notificationId, count, total, url, file!!)
}
count++
}
}
private val notificationId: Int
get() = abs(id.hashCode())
private fun download(
notificationId: Int,
position: Int,
total: Int,
url: String,
filePath: DocumentFile,
) {
val context = applicationContext.let { it }
val contentResolver = context.contentResolver?.let { it } ?: return
val filePathType = filePath.type?.let { it } ?: return
val isJpg = filePathType.startsWith("image")
// using temp file approach to remove IPTC so that download progress can be reported
val outFile = if (isJpg) DownloadUtils.getTempFile(null, "jpg") else filePath
try {
val urlConnection = URL(url).openConnection()
val fileSize = if (Build.VERSION.SDK_INT >= 24) urlConnection.contentLengthLong else urlConnection.contentLength.toLong()
var totalRead = 0f
try {
BufferedInputStream(urlConnection.getInputStream()).use { bis ->
contentResolver.openOutputStream(outFile!!.uri).use { fos ->
val buffer = ByteArray(0x2000)
var count: Int
while (bis.read(buffer, 0, 0x2000).also { count = it } != -1) {
totalRead += count
fos!!.write(buffer, 0, count)
setProgressAsync(Data.Builder().putString(URL, url)
.putFloat(PROGRESS, totalRead * 100f / fileSize)
.build())
updateDownloadProgress(notificationId, position, total, totalRead * 100f / fileSize)
}
fos!!.flush()
}
}
} catch (e: Exception) {
Log.e(TAG, "Error while writing data from url: " + url + " to file: " + outFile!!.name, e)
}
if (isJpg) {
try {
contentResolver.openInputStream(outFile!!.uri).use { fis ->
contentResolver.openOutputStream(filePath.uri).use { fos ->
val jpegIptcRewriter = JpegIptcRewriter()
jpegIptcRewriter.removeIPTC(fis, fos)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error while removing iptc: url: " + url
+ ", tempFile: " + outFile!!.name
+ ", finalFile: " + filePath.name, e)
}
val deleted = outFile!!.delete()
if (!deleted) {
Log.w(TAG, "download: tempFile not deleted!")
}
}
} catch (e: Exception) {
Log.e(TAG, "Error while downloading: $url", e)
}
setProgressAsync(Data.Builder().putString(URL, url)
.putFloat(PROGRESS, 100f)
.build())
updateDownloadProgress(notificationId, position, total, 100f)
}
private fun updateDownloadProgress(
notificationId: Int,
position: Int,
total: Int,
percent: Float,
) {
val notification = createProgressNotification(position, total, percent)
try {
if (notification == null) {
notificationManager.cancel(notificationId)
return
}
setForegroundAsync(ForegroundInfo(notificationId, notification)).get()
} catch (e: ExecutionException) {
Log.e(TAG, "updateDownloadProgress", e)
} catch (e: InterruptedException) {
Log.e(TAG, "updateDownloadProgress", e)
}
}
private fun createProgressNotification(position: Int, total: Int, percent: Float): Notification? {
val context = applicationContext
var ongoing = true
val totalPercent: Int
if (position == total && percent == 100f) {
ongoing = false
totalPercent = 100
} else {
totalPercent = (100f * (position - 1) / total + 1f / total * percent).toInt()
}
if (totalPercent == 100) {
return null
}
// Log.d(TAG, "createProgressNotification: position: " + position
// + ", total: " + total
// + ", percent: " + percent
// + ", totalPercent: " + totalPercent);
val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setSmallIcon(R.drawable.ic_download)
.setOngoing(ongoing)
.setProgress(100, totalPercent, totalPercent < 0)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setContentTitle(context.getString(R.string.downloader_downloading_post))
if (total > 1) {
builder.setContentText(context.getString(R.string.downloader_downloading_child, position, total))
}
return builder.build()
}
private fun showSummary(urlToFilePathMap: Map<String, String>) {
val context = applicationContext
val filePaths = urlToFilePathMap.mapNotNull { DocumentFile.fromSingleUri(context, Uri.parse(it.value)) }
val notifications: MutableList<NotificationCompat.Builder> = LinkedList()
val notificationIds: MutableList<Int> = LinkedList()
var count = 1
for (filePath: DocumentFile in filePaths) {
// final File file = new File(filePath);
// context.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, filePath.getUri()));
// Utils.scanDocumentFile(context, filePath, (path, uri) -> {});
// final Uri uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file);
val contentResolver = context.contentResolver
var bitmap: Bitmap? = null
val mimeType = filePath.type // Utils.getMimeType(uri, contentResolver);
if (!isEmpty(mimeType)) {
if (mimeType!!.startsWith("image")) {
try {
contentResolver.openInputStream(filePath.uri).use { inputStream ->
bitmap = BitmapFactory.decodeStream(inputStream)
}
} catch (e: java.lang.Exception) {
if (BuildConfig.DEBUG) Log.e(TAG, "", e)
}
} else if (mimeType.startsWith("video")) {
val retriever = MediaMetadataRetriever()
try {
try {
retriever.setDataSource(context, filePath.uri)
} catch (e: java.lang.Exception) {
// retriever.setDataSource(file.getAbsolutePath());
Log.e(TAG, "showSummary: ", e)
}
bitmap = retriever.frameAtTime
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) try {
retriever.close()
} catch (e: java.lang.Exception) {
Log.e(TAG, "showSummary: ", e)
}
} catch (e: java.lang.Exception) {
Log.e(TAG, "", e)
}
}
}
val downloadComplete = context.getString(R.string.downloader_complete)
val intent = Intent(Intent.ACTION_VIEW, filePath.uri)
.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_FROM_BACKGROUND
or Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
.putExtra(Intent.EXTRA_STREAM, filePath.uri)
val pendingIntent = PendingIntent.getActivity(
context,
DOWNLOAD_NOTIFICATION_INTENT_REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT
)
val notificationId: Int = notificationId + count
notificationIds.add(notificationId)
count++
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_download)
.setContentText(null)
.setContentTitle(downloadComplete)
.setWhen(System.currentTimeMillis())
.setOnlyAlertOnce(true)
.setAutoCancel(true)
.setGroup(NOTIF_GROUP_NAME + "_" + id)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.setContentIntent(pendingIntent)
.addAction(
R.drawable.ic_delete,
context.getString(R.string.delete),
DeleteImageIntentService.pendingIntent(context, filePath, notificationId)
)
if (bitmap != null) {
builder.setLargeIcon(bitmap)
.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(bitmap)
.bigLargeIcon(null)
)
.setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL)
}
notifications.add(builder)
}
var summaryNotification: Notification? = null
if (urlToFilePathMap.size != 1) {
val text = "Downloaded " + urlToFilePathMap.size + " items"
summaryNotification = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID)
.setContentTitle("Downloaded")
.setContentText(text)
.setSmallIcon(R.drawable.ic_download)
.setStyle(NotificationCompat.InboxStyle().setSummaryText(text))
.setGroup(NOTIF_GROUP_NAME + "_" + id)
.setGroupSummary(true)
.build()
}
for (i in notifications.indices) {
val builder = notifications[i]
// only make sound and vibrate for the last notification
if (i != notifications.size - 1) {
builder.setSound(null)
.setVibrate(null)
}
notificationManager.notify(notificationIds[i], builder.build())
}
if (summaryNotification != null) {
notificationManager.notify(notificationId + count, summaryNotification)
}
}
private fun getThumbnail(
context: Context,
file: File,
uri: Uri,
contentResolver: ContentResolver,
): Bitmap? {
val mimeType = Utils.getMimeType(uri, contentResolver)
if (isEmpty(mimeType)) return null
var bitmap: Bitmap? = null
if (mimeType.startsWith("image")) {
try {
val bitmapResult = BitmapUtils.getBitmapResult(
context.contentResolver,
uri,
BitmapUtils.THUMBNAIL_SIZE,
BitmapUtils.THUMBNAIL_SIZE,
-1f,
true
) ?: return null
bitmap = bitmapResult.bitmap
} catch (e: Exception) {
Log.e(TAG, "", e)
}
return bitmap
}
if (mimeType.startsWith("video")) {
try {
val retriever = MediaMetadataRetriever()
bitmap = try {
try {
retriever.setDataSource(context, uri)
} catch (e: Exception) {
retriever.setDataSource(file.absolutePath)
}
retriever.frameAtTime
} finally {
try {
retriever.release()
} catch (e: Exception) {
Log.e(TAG, "getThumbnail: ", e)
}
}
} catch (e: Exception) {
Log.e(TAG, "", e)
}
}
return bitmap
}
class DownloadRequest private constructor(val urlToFilePathMap: Map<String, String>) {
class Builder {
private var urlToFilePathMap: MutableMap<String, String> = mutableMapOf()
fun setUrlToFilePathMap(urlToFilePathMap: Map<String, DocumentFile?>): Builder {
this.urlToFilePathMap = urlToFilePathMap
.filter{ it.value != null }
.mapValues { it.value!!.uri.toString() }
.toMutableMap()
return this
}
fun addUrl(url: String, filePath: DocumentFile): Builder {
urlToFilePathMap[url] = filePath.uri.toString()
return this
}
fun build(): DownloadRequest {
return DownloadRequest(urlToFilePathMap)
}
}
companion object {
@JvmStatic
fun builder(): Builder {
return Builder()
}
}
}
companion object {
const val PROGRESS = "PROGRESS"
const val URL = "URL"
const val KEY_DOWNLOAD_REQUEST_JSON = "download_request_json"
private const val DOWNLOAD_GROUP = "DOWNLOAD_GROUP"
private const val DOWNLOAD_NOTIFICATION_INTENT_REQUEST_CODE = 2020
private const val DELETE_IMAGE_REQUEST_CODE = 2030
}
}