Skip to content

Instantly share code, notes, and snippets.

@umpteenthdev
Last active March 14, 2019 05:18
Show Gist options
  • Select an option

  • Save umpteenthdev/e66793207271a4a31412ec1f7ec6b247 to your computer and use it in GitHub Desktop.

Select an option

Save umpteenthdev/e66793207271a4a31412ec1f7ec6b247 to your computer and use it in GitHub Desktop.
File uploader, image compressor (Android). Kotlin. Coroutines.

Dependencies

implementation "androidx.exifinterface:exifinterface:1.0.0"

import android.graphics.*
import androidx.exifinterface.media.ExifInterface
import kotlinx.io.ByteArrayOutputStream
import kotlin.math.min
import kotlin.math.roundToInt
fun ByteArray.compressAsImage(maxHeight: Float, maxWidth: Float, quality: Int): ByteArray {
val options = getInitialBitmapOptions(this)
val initialHeight = options.outHeight
val initialWidth = options.outWidth
val (targetHeight, targetWidth) = getTargetHeightAndWidth(initialHeight, initialWidth, maxHeight, maxWidth)
options.inSampleSize = calculateInSampleSize(initialHeight, initialWidth, targetWidth, targetHeight)
options.inJustDecodeBounds = false
options.inTempStorage = ByteArray(16 * 1024)
val scaledBitmap = scaleBitmap(this, options, targetWidth, targetHeight)
val rotatedBitmap = rotateBitmap(this, scaledBitmap)
return ByteArrayOutputStream().use {
rotatedBitmap.compress(Bitmap.CompressFormat.JPEG, quality, it)
rotatedBitmap.recycle()
it.toByteArray()
}
}
private fun getInitialBitmapOptions(data: ByteArray): BitmapFactory.Options {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeByteArray(data, 0, data.size, options)
return options
}
private fun getTargetHeightAndWidth(initialHeight: Int, initialWidth: Int, maxHeight: Float, maxWidth: Float): Pair<Int, Int> {
val initialRatio = initialWidth.toFloat() / initialHeight.toFloat()
val maxRatio = maxWidth / maxHeight
return if (initialHeight > maxHeight || initialWidth > maxWidth) {
when {
initialRatio < maxRatio -> {
val heightRatio = maxHeight / initialHeight
maxHeight.toInt() to (heightRatio * initialWidth).toInt()
}
initialRatio > maxRatio -> {
val widthRatio = maxWidth / initialWidth
(widthRatio * initialHeight).toInt() to maxWidth.toInt()
}
else -> maxHeight.toInt() to maxWidth.toInt()
}
} else {
initialHeight to initialWidth
}
}
private fun calculateInSampleSize(initialHeight: Int, initialWidth: Int, requiredWidth: Int, requiredHeight: Int): Int {
var inSampleSize = 1
if (initialHeight > requiredHeight || initialWidth > requiredWidth) {
val heightRatio = (initialHeight.toFloat() / requiredHeight.toFloat()).roundToInt()
val widthRatio = (initialWidth.toFloat() / requiredWidth.toFloat()).roundToInt()
inSampleSize = min(heightRatio, widthRatio)
}
val totalPixels = (initialWidth * initialHeight).toFloat()
val totalReqPixelsCap = (requiredWidth * requiredHeight * 2).toFloat()
while (totalPixels / (inSampleSize * inSampleSize) > totalReqPixelsCap) inSampleSize++
return inSampleSize
}
private fun rotateBitmap(data: ByteArray, bitmap: Bitmap): Bitmap {
val exif = ExifInterface(data.inputStream())
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0)
val matrix = Matrix()
when (orientation) {
6 -> matrix.postRotate(90f)
3 -> matrix.postRotate(180f)
8 -> matrix.postRotate(270f)
}
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
private fun scaleBitmap(data: ByteArray, options: BitmapFactory.Options, width: Int, height: Int): Bitmap {
val bmp = BitmapFactory.decodeByteArray(data, 0, data.size, options)
val ratioX = width / options.outWidth.toFloat()
val ratioY = height / options.outHeight.toFloat()
val middleX = width / 2.0f
val middleY = height / 2.0f
val scaleMatrix = Matrix()
scaleMatrix.setScale(ratioX, ratioY, middleX, middleY)
val scaledBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(scaledBitmap)
canvas.matrix = scaleMatrix
canvas.drawBitmap(bmp, middleX - bmp.width / 2, middleY - bmp.height / 2, Paint(Paint.FILTER_BITMAP_FLAG))
return scaledBitmap
}
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
abstract class CompressedImageFileUploader<TUploadResult, TParams>(
fs: IFileSystem,
dispatchers: ICoroutineDispatchers,
private val maxHeight: Float,
private val maxWidth: Float,
private val quality: Int
) : FileUploader<TUploadResult, TParams>(fs, dispatchers, useCompression = true) {
override suspend fun compress(bytes: ByteArray, scope: CoroutineScope): Deferred<ByteArray> = scope.async {
bytes.compressAsImage(maxHeight, maxWidth, quality)
}
}
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
abstract class FileUploader<TUploadResult, TParams>(
private val fs: IFileSystem,
private val dispatchers: ICoroutineDispatchers,
private val useCompression: Boolean
) {
private var callbacks: MutableList<Callback<TUploadResult>> = mutableListOf()
private val sendFilesScope = CoroutineScope(dispatchers.io + SupervisorJob())
private val jobs: MutableMap<String, Job> = mutableMapOf()
private val loadState: MutableMap<File, UploadingStatus> = mutableMapOf()
private val results: MutableMap<File, TUploadResult> = mutableMapOf()
private val fsReadMutex = Mutex()
protected abstract suspend fun upload(file: File, scope: CoroutineScope, params: TParams): Deferred<TUploadResult>
protected open suspend fun compress(bytes: ByteArray, scope: CoroutineScope): Deferred<ByteArray> =
scope.async { bytes }
fun createAndSendFile(name: String, bytes: ByteArray, params: TParams) = sendFilesScope.launch {
var file: File? = null
try {
removeAndCancelJob(name)
checkFileName(name)
file = fs.recreateCacheFile(name)
if (useCompression) {
file.writeBytes(compress(bytes, sendFilesScope).await())
} else {
file.writeBytes(bytes)
}
updateLoadState(file, UploadingStatus.IN_PROGRESS)
val result = upload(file, params)
updateLoadState(file, UploadingStatus.COMPLETED)
notifyOnSuccess(file, result)
} catch (th: Throwable) {
catchUploadingError(th, file)
}
}
fun sendFile(file: File, params: TParams) = sendFilesScope.launch {
try {
removeAndCancelJob(file.name)
if (useCompression) file.writeBytes(compress(file.readBytes(), sendFilesScope).await())
updateLoadState(file, UploadingStatus.IN_PROGRESS)
val result = upload(file, params)
updateLoadState(file, UploadingStatus.COMPLETED)
notifyOnSuccess(file, result)
} catch (th: Throwable) {
catchUploadingError(th, file)
}
}
fun resendFile(name: String, params: TParams) = sendFilesScope.launch {
var file: File? = null
try {
removeAndCancelJob(name)
checkFileName(name)
file = getCacheFile(name) ?: throw IllegalArgumentException("File does not exists")
updateLoadState(file, UploadingStatus.IN_PROGRESS)
val result = upload(file, params)
updateLoadState(file, UploadingStatus.COMPLETED)
notifyOnSuccess(file, result)
} catch (th: Throwable) {
catchUploadingError(th, file)
}
}
fun addOnResultListener(listener: Callback<TUploadResult>) {
callbacks.add(listener)
listener.onNewUploadState(getImmutableState())
listener.onSuccess(getImmutableResults())
}
fun removeOnResultListener(listener: Callback<TUploadResult>) {
callbacks.remove(listener)
}
fun cancel(filename: String) {
sendFilesScope.launch {
removeAndCancelJob(filename)
fs.deleteCacheFile(filename)
deleteFromState(filename)
deleteFromResults(filename)
notifyOnNewLoadState()
}
}
fun cancelAll(deleteFiles: Boolean) {
sendFilesScope.launch(dispatchers.main) {
try {
jobs.values.forEach { it.cancelAndJoin() }
jobs.clear()
if (deleteFiles) deleteFiles(loadState.keys)
loadState.clear()
results.clear()
notifyOnNewLoadState()
} catch (th: Throwable) {
notifyOnError(th)
}
}
}
private fun deleteFiles(files: Set<File>) {
sendFilesScope.launch {
try {
files.forEach { fs.deleteCacheFile(it.name) }
} catch (th: Throwable) {
notifyOnError(th)
}
}
}
private suspend fun upload(file: File, params: TParams): TUploadResult {
val task = upload(file, sendFilesScope, params)
withContext(dispatchers.main) { jobs[file.name] = task }
return task.await()
}
private suspend fun catchUploadingError(th: Throwable, file: File?) = withContext(dispatchers.main) {
if (file != null && jobs[file.name] != null) {
updateLoadState(file, UploadingStatus.ERROR)
removeAndCancelJob(file.name)
}
if (th !is CancellationException) notifyOnError(th)
}
private fun checkFileName(name: String) {
if (name.contains("""\W""".toRegex())) throw IllegalArgumentException("File name contains illegal character")
}
private suspend fun notifyOnSuccess(file: File, result: TUploadResult) = withContext(dispatchers.main) {
results[file] = result
val results = getImmutableResults()
callbacks.forEach { it.onSuccess(results) }
}
private suspend fun notifyOnError(th: Throwable) = withContext(dispatchers.main) {
callbacks.forEach { it.onError(th) }
}
private suspend fun notifyOnNewLoadState() = withContext(dispatchers.main) {
val state = getImmutableState()
callbacks.forEach { it.onNewUploadState(state) }
}
private fun getImmutableState() = loadState.toMap()
private fun getImmutableResults() = results.toMap()
private suspend fun updateLoadState(file: File, status: UploadingStatus) = withContext(dispatchers.main) {
loadState[file] = status
notifyOnNewLoadState()
}
private suspend fun deleteFromState(filename: String) = withContext(dispatchers.main) {
loadState.keys.firstOrNull { it.name == filename }?.let {
loadState.remove(it)
}
}
private suspend fun deleteFromResults(filename: String) = withContext(dispatchers.main) {
results.keys.firstOrNull { it.name == filename }?.let {
results.remove(it)
}
}
private suspend fun removeAndCancelJob(name: String) = withContext(dispatchers.main) {
jobs.remove(name)?.takeIf { it.isActive }?.cancelAndJoin()
}
private suspend fun getCacheFile(fileName: String): File? {
return fsReadMutex.withLock { fs.getCacheFile(fileName) }
}
interface Callback<TUploadResult> {
fun onSuccess(results: Map<File, TUploadResult>)
fun onError(th: Throwable)
fun onNewUploadState(state: Map<File, UploadingStatus>)
}
}
interface ICoroutineDispatchers {
val main: CoroutineDispatcher
val io: CoroutineDispatcher
}
interface IFileSystem {
fun getCacheFile(fileName: String): File?
fun recreateCacheFile(fileName: String): File
fun deleteCacheFile(fileName: String)
}
enum class UploadingStatus {
NOT_STARTED, IN_PROGRESS, COMPLETED, ERROR
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment