implementation "androidx.exifinterface:exifinterface:1.0.0"
Last active
March 14, 2019 05:18
-
-
Save umpteenthdev/e66793207271a4a31412ec1f7ec6b247 to your computer and use it in GitHub Desktop.
File uploader, image compressor (Android). Kotlin. Coroutines.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| interface ICoroutineDispatchers { | |
| val main: CoroutineDispatcher | |
| val io: CoroutineDispatcher | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| interface IFileSystem { | |
| fun getCacheFile(fileName: String): File? | |
| fun recreateCacheFile(fileName: String): File | |
| fun deleteCacheFile(fileName: String) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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