package org.mariotaku.twidere.task.twitter import android.content.ContentResolver import android.content.ContentValues import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Point import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build import android.support.annotation.UiThread import android.support.annotation.WorkerThread import android.support.media.ExifInterface import android.text.TextUtils import android.webkit.MimeTypeMap import com.twitter.Validator import net.ypresto.androidtranscoder.MediaTranscoder import net.ypresto.androidtranscoder.format.MediaFormatStrategyPresets import org.mariotaku.ktextension.* import org.mariotaku.library.objectcursor.ObjectCursor import org.mariotaku.microblog.library.MicroBlog import org.mariotaku.microblog.library.MicroBlogException import org.mariotaku.microblog.library.fanfou.model.PhotoStatusUpdate import org.mariotaku.microblog.library.mastodon.Mastodon import org.mariotaku.microblog.library.mastodon.model.Attachment import org.mariotaku.microblog.library.twitter.TwitterUpload import org.mariotaku.microblog.library.twitter.model.ErrorInfo import org.mariotaku.microblog.library.twitter.model.MediaUploadResponse import org.mariotaku.microblog.library.twitter.model.NewMediaMetadata import org.mariotaku.microblog.library.twitter.model.StatusUpdate import org.mariotaku.restfu.http.ContentType import org.mariotaku.restfu.http.mime.Body import org.mariotaku.restfu.http.mime.FileBody import org.mariotaku.restfu.http.mime.SimpleBody import org.mariotaku.sqliteqb.library.Expression import org.mariotaku.twidere.R import org.mariotaku.twidere.TwidereConstants.* import org.mariotaku.twidere.alias.MastodonStatusUpdate import org.mariotaku.twidere.annotation.AccountType import org.mariotaku.twidere.app.TwidereApplication import org.mariotaku.twidere.extension.model.api.mastodon.toParcelable import org.mariotaku.twidere.extension.model.api.toParcelable import org.mariotaku.twidere.extension.model.applyUpdateStatus import org.mariotaku.twidere.extension.model.mediaSizeLimit import org.mariotaku.twidere.extension.model.newMicroBlogInstance import org.mariotaku.twidere.extension.model.textLimit import org.mariotaku.twidere.extension.text.twitter.getTweetLength import org.mariotaku.twidere.model.* import org.mariotaku.twidere.model.account.AccountExtras import org.mariotaku.twidere.model.analyzer.UpdateStatus import org.mariotaku.twidere.model.schedule.ScheduleInfo import org.mariotaku.twidere.model.util.ParcelableLocationUtils import org.mariotaku.twidere.preference.ComponentPickerPreference import org.mariotaku.twidere.provider.TwidereDataStore.Drafts import org.mariotaku.twidere.task.BaseAbstractTask import org.mariotaku.twidere.util.* import org.mariotaku.twidere.util.io.ContentLengthInputStream import org.mariotaku.twidere.util.premium.ExtraFeaturesService import java.io.Closeable import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.util.* import java.util.concurrent.TimeUnit /** * Update status * * Created by mariotaku on 16/5/22. */ class UpdateStatusTask( context: Context, internal val stateCallback: UpdateStatusTask.StateCallback ) : BaseAbstractTask, UpdateStatusTask.UpdateStatusResult, Any?>(context) { override fun doLongOperation(params: Pair): UpdateStatusResult { val (update, info) = params val draftId = saveDraft(context, update.draft_action ?: Draft.Action.UPDATE_STATUS) { applyUpdateStatus(update) } microBlogWrapper.addSendingDraftId(draftId) try { val result = doUpdateStatus(update, info, draftId) deleteOrUpdateDraft(update, result, draftId) return result } catch (e: UpdateStatusException) { return UpdateStatusResult(e, draftId) } finally { microBlogWrapper.removeSendingDraftId(draftId) } } override fun beforeExecute() { stateCallback.beforeExecute() } override fun afterExecute(callback: Any?, result: UpdateStatusResult) { stateCallback.afterExecute(result) logUpdateStatus(params.first, result) } private fun logUpdateStatus(statusUpdate: ParcelableStatusUpdate, result: UpdateStatusResult) { val mediaType = statusUpdate.media?.firstOrNull()?.type ?: ParcelableMedia.Type.UNKNOWN val hasLocation = statusUpdate.location != null val preciseLocation = statusUpdate.display_coordinates Analyzer.log(UpdateStatus(result.accountTypes.firstOrNull(), statusUpdate.draft_action, mediaType, hasLocation, preciseLocation, result.succeed, result.exceptions.firstOrNull() ?: result.exception)) } @Throws(UpdateStatusException::class) private fun doUpdateStatus(update: ParcelableStatusUpdate, info: ScheduleInfo?, draftId: Long): UpdateStatusResult { val app = TwidereApplication.getInstance(context) val uploader = getMediaUploader(app) val shortener = getStatusShortener(app) val pendingUpdate = PendingStatusUpdate(update) val result: UpdateStatusResult try { uploadMedia(uploader, update, info, pendingUpdate) shortenStatus(shortener, update, pendingUpdate) if (info != null) { result = requestScheduleStatus(update, pendingUpdate, info, draftId) } else { result = requestUpdateStatus(update, pendingUpdate, draftId) } mediaUploadCallback(uploader, pendingUpdate, result) statusShortenCallback(shortener, pendingUpdate, result) // Cleanup pendingUpdate.deleteOnSuccess.forEach { item -> item.delete(context) } } catch (e: UploadException) { e.deleteAlways?.forEach { it.delete(context) } throw e } finally { // Cleanup pendingUpdate.deleteAlways.forEach { item -> item.delete(context) } uploader?.unbindService() shortener?.unbindService() } return result } private fun deleteOrUpdateDraft(update: ParcelableStatusUpdate, result: UpdateStatusResult, draftId: Long) { val where = Expression.equals(Drafts._ID, draftId).sql var hasError = false val failedAccounts = ArrayList() for (i in update.accounts.indices) { val exception = result.exceptions[i] if (exception != null && !isDuplicate(exception)) { hasError = true failedAccounts.add(update.accounts[i].key) } } val cr = context.contentResolver if (hasError) { val values = ContentValues() values.put(Drafts.ACCOUNT_KEYS, failedAccounts.joinToString(",")) cr.update(Drafts.CONTENT_URI, values, where, null) // TODO show error message } else { cr.delete(Drafts.CONTENT_URI, where, null) } } @Throws(UploadException::class) private fun uploadMedia(uploader: MediaUploaderInterface?, update: ParcelableStatusUpdate, info: ScheduleInfo?, pendingUpdate: PendingStatusUpdate) { stateCallback.onStartUploadingMedia() if (uploader != null) { uploadMediaWithExtension(uploader, update, pendingUpdate) } else if (info == null) { uploadMediaWithDefaultProvider(update, pendingUpdate) } } @Throws(UploadException::class) private fun uploadMediaWithExtension(uploader: MediaUploaderInterface, update: ParcelableStatusUpdate, pending: PendingStatusUpdate) { uploader.waitForService() val media: Array try { media = UploaderMediaItem.getFromStatusUpdate(context, update) } catch (e: FileNotFoundException) { throw UploadException(e) } val sharedMedia = HashMap() for (i in 0..pending.length - 1) { val account = update.accounts[i] // Skip upload if shared media found val accountKey = account.key var uploadResult: MediaUploadResult? = sharedMedia[accountKey] if (uploadResult == null) { uploadResult = uploader.upload(update, accountKey, media) ?: run { throw UploadException() } if (uploadResult.media_uris == null) { throw UploadException(uploadResult.error_message ?: "Unknown error") } pending.mediaUploadResults[i] = uploadResult if (uploadResult.shared_owners != null) { for (sharedOwner in uploadResult.shared_owners) { sharedMedia.put(sharedOwner, uploadResult) } } } // Override status text pending.overrideTexts[i] = "${pending.overrideTexts[i]} ${uploadResult.media_uris.joinToString(" ")}" } } @Throws(UpdateStatusException::class) private fun shortenStatus(shortener: StatusShortenerInterface?, update: ParcelableStatusUpdate, pending: PendingStatusUpdate) { if (shortener == null) return val validator = Validator() stateCallback.onShorteningStatus() val sharedShortened = HashMap() for (i in 0 until pending.length) { val account = update.accounts[i] val text = pending.overrideTexts[i] val textLimit = account.textLimit val ignoreMentions = account.type == AccountType.TWITTER if (textLimit >= 0 && validator.getTweetLength(text, ignoreMentions, update.in_reply_to_status, account.key) <= textLimit) { continue } shortener.waitForService() // Skip upload if this shared media found val accountKey = account.key var shortenResult: StatusShortenResult? = sharedShortened[accountKey] if (shortenResult == null) { shortenResult = shortener.shorten(update, accountKey, text) ?: run { throw ShortenException() } if (shortenResult.shortened == null) { throw ShortenException(shortenResult.error_message ?: "Unknown error") } pending.statusShortenResults[i] = shortenResult if (shortenResult.shared_owners != null) { for (sharedOwner in shortenResult.shared_owners) { sharedShortened.put(sharedOwner, shortenResult) } } } // Override status text pending.overrideTexts[i] = shortenResult.shortened } } @Throws(UpdateStatusException::class) private fun requestScheduleStatus( statusUpdate: ParcelableStatusUpdate, pendingUpdate: PendingStatusUpdate, scheduleInfo: ScheduleInfo, draftId: Long ): UpdateStatusResult { stateCallback.onUpdatingStatus() if (!extraFeaturesService.isEnabled(ExtraFeaturesService.FEATURE_SCHEDULE_STATUS)) { throw SchedulerNotFoundException(context.getString(R.string.error_message_scheduler_not_available)) } val controller = scheduleProvider ?: run { throw SchedulerNotFoundException(context.getString(R.string.error_message_scheduler_not_available)) } controller.scheduleStatus(statusUpdate, pendingUpdate, scheduleInfo) return UpdateStatusResult(pendingUpdate.length, draftId) } @Throws(UpdateStatusException::class) private fun requestUpdateStatus( statusUpdate: ParcelableStatusUpdate, pendingUpdate: PendingStatusUpdate, draftId: Long ): UpdateStatusResult { stateCallback.onUpdatingStatus() val result = UpdateStatusResult(pendingUpdate.length, draftId) for (i in 0 until pendingUpdate.length) { val account = statusUpdate.accounts[i] result.accountTypes[i] = account.type try { val status = when (account.type) { AccountType.FANFOU -> { val microBlog = account.newMicroBlogInstance(context, MicroBlog::class.java) // Call uploadPhoto if media present if (statusUpdate.media.isNotNullOrEmpty()) { // Fanfou only allow one photo fanfouUpdateStatusWithPhoto(microBlog, statusUpdate, pendingUpdate, account.mediaSizeLimit, i) } else { twitterUpdateStatus(microBlog, statusUpdate, pendingUpdate, i) } } AccountType.MASTODON -> { val mastodon = account.newMicroBlogInstance(context, Mastodon::class.java) mastodonUpdateStatus(mastodon, statusUpdate, pendingUpdate, i) } else -> { val microBlog = account.newMicroBlogInstance(context, MicroBlog::class.java) twitterUpdateStatus(microBlog, statusUpdate, pendingUpdate, i) } } result.statuses[i] = status } catch (e: MicroBlogException) { result.exceptions[i] = e } } return result } @Throws(MicroBlogException::class, UploadException::class) private fun fanfouUpdateStatusWithPhoto(microBlog: MicroBlog, statusUpdate: ParcelableStatusUpdate, pendingUpdate: PendingStatusUpdate, sizeLimit: SizeLimit?, updateIndex: Int): ParcelableStatus { if (statusUpdate.media.size > 1) { throw MicroBlogException(context.getString(R.string.error_too_many_photos_fanfou)) } val media = statusUpdate.media.first() try { val details = statusUpdate.accounts[updateIndex] return getBodyFromMedia(context, media, sizeLimit, false, ContentLengthInputStream.ReadListener { length, position -> stateCallback.onUploadingProgressChanged(-1, position, length) }).use { (body) -> val photoUpdate = PhotoStatusUpdate(body, pendingUpdate.overrideTexts[updateIndex]) return@use microBlog.uploadPhoto(photoUpdate) }.toParcelable(details) } catch (e: IOException) { throw UploadException(e) } } /** * Calling Twitter's upload method. This method sets multiple owner for bandwidth saving */ @Throws(UploadException::class) private fun uploadMediaWithDefaultProvider(update: ParcelableStatusUpdate, pendingUpdate: PendingStatusUpdate) { // Return empty array if no media attached if (update.media.isNullOrEmpty()) return val ownersList = update.accounts.filter { AccountType.TWITTER == it.type }.map(AccountDetails::key) val ownerIds = ownersList.map { it.id }.toTypedArray() for (i in 0..pendingUpdate.length - 1) { val account = update.accounts[i] val mediaIds: Array? when (account.type) { AccountType.TWITTER -> { val upload = account.newMicroBlogInstance(context, cls = TwitterUpload::class.java) if (pendingUpdate.sharedMediaIds != null) { mediaIds = pendingUpdate.sharedMediaIds } else { val (ids, deleteOnSuccess, deleteAlways) = uploadMicroBlogMediaShared(context, upload, account, update.media, null, ownerIds, true, stateCallback) mediaIds = ids deleteOnSuccess.addAllTo(pendingUpdate.deleteOnSuccess) deleteAlways.addAllTo(pendingUpdate.deleteAlways) pendingUpdate.sharedMediaIds = mediaIds } } AccountType.FANFOU -> { // Nope, fanfou uses photo uploading API mediaIds = null } AccountType.STATUSNET -> { // TODO use their native API val upload = account.newMicroBlogInstance(context, cls = TwitterUpload::class.java) val (ids, deleteOnSuccess, deleteAlways) = uploadMicroBlogMediaShared(context, upload, account, update.media, null, ownerIds, false, stateCallback) mediaIds = ids deleteOnSuccess.addAllTo(pendingUpdate.deleteOnSuccess) deleteAlways.addAllTo(pendingUpdate.deleteAlways) } AccountType.MASTODON -> { val mastodon = account.newMicroBlogInstance(context, cls = Mastodon::class.java) val (ids, deleteOnSuccess, deleteAlways) = uploadMastodonMedia(context, mastodon, account, update.media, false, stateCallback) mediaIds = ids deleteOnSuccess.addAllTo(pendingUpdate.deleteOnSuccess) deleteAlways.addAllTo(pendingUpdate.deleteAlways) } else -> { mediaIds = null } } pendingUpdate.mediaIds[i] = mediaIds } pendingUpdate.sharedMediaOwners = ownersList.toTypedArray() } @Throws(MicroBlogException::class) private fun twitterUpdateStatus(microBlog: MicroBlog, statusUpdate: ParcelableStatusUpdate, pendingUpdate: PendingStatusUpdate, index: Int): ParcelableStatus { val overrideText = pendingUpdate.overrideTexts[index] val status = StatusUpdate(overrideText) val inReplyToStatus = statusUpdate.in_reply_to_status val details = statusUpdate.accounts[index] if (statusUpdate.draft_action == Draft.Action.REPLY && inReplyToStatus != null) { status.inReplyToStatusId(inReplyToStatus.id) if (details.type == AccountType.TWITTER && statusUpdate.extended_reply_mode) { status.autoPopulateReplyMetadata(true) if (statusUpdate.excluded_reply_user_ids.isNotNullOrEmpty()) { status.excludeReplyUserIds(statusUpdate.excluded_reply_user_ids) } } } if (statusUpdate.repost_status_id != null) { status.repostStatusId(statusUpdate.repost_status_id) } if (statusUpdate.attachment_url != null) { status.attachmentUrl(statusUpdate.attachment_url) } if (statusUpdate.location != null) { status.location(ParcelableLocationUtils.toGeoLocation(statusUpdate.location)) status.displayCoordinates(statusUpdate.display_coordinates) } val mediaIds = pendingUpdate.mediaIds[index] if (mediaIds != null) { status.mediaIds(mediaIds) } if (statusUpdate.is_possibly_sensitive) { status.possiblySensitive(statusUpdate.is_possibly_sensitive) } return microBlog.updateStatus(status).toParcelable(details) } @Throws(MicroBlogException::class) private fun mastodonUpdateStatus(mastodon: Mastodon, statusUpdate: ParcelableStatusUpdate, pendingUpdate: PendingStatusUpdate, index: Int): ParcelableStatus { val overrideText = pendingUpdate.overrideTexts[index] val status = MastodonStatusUpdate(overrideText) val inReplyToStatus = statusUpdate.in_reply_to_status val details = statusUpdate.accounts[index] if (statusUpdate.draft_action == Draft.Action.REPLY && inReplyToStatus != null) { status.inReplyToId(inReplyToStatus.id) } val mediaIds = pendingUpdate.mediaIds[index] if (mediaIds != null) { status.mediaIds(mediaIds) } if (statusUpdate.is_possibly_sensitive) { status.sensitive(statusUpdate.is_possibly_sensitive) } if (statusUpdate.summary != null) { status.spoilerText(statusUpdate.summary) } if (statusUpdate.visibility != null) { status.visibility(statusUpdate.visibility) } return mastodon.postStatus(status).toParcelable(details) } private fun statusShortenCallback(shortener: StatusShortenerInterface?, pendingUpdate: PendingStatusUpdate, updateResult: UpdateStatusResult) { if (shortener == null || !shortener.waitForService()) return for (i in 0..pendingUpdate.length - 1) { val shortenResult = pendingUpdate.statusShortenResults[i] val status = updateResult.statuses[i] if (shortenResult == null || status == null) continue shortener.callback(shortenResult, status) } } private fun mediaUploadCallback(uploader: MediaUploaderInterface?, pendingUpdate: PendingStatusUpdate, updateResult: UpdateStatusResult) { if (uploader == null || !uploader.waitForService()) return for (i in 0..pendingUpdate.length - 1) { val uploadResult = pendingUpdate.mediaUploadResults[i] val status = updateResult.statuses[i] if (uploadResult == null || status == null) continue uploader.callback(uploadResult, status) } } @Throws(UploaderNotFoundException::class, UploadException::class, ShortenerNotFoundException::class, ShortenException::class) private fun getStatusShortener(app: TwidereApplication): StatusShortenerInterface? { val shortenerComponent = preferences.getString(KEY_STATUS_SHORTENER, null) if (ComponentPickerPreference.isNoneValue(shortenerComponent)) return null val shortener = StatusShortenerInterface.getInstance(app, shortenerComponent) ?: throw ShortenerNotFoundException() try { shortener.checkService { metaData -> if (metaData == null) throw ExtensionVersionMismatchException() val extensionVersion = metaData.getString(METADATA_KEY_EXTENSION_VERSION_STATUS_SHORTENER) if (!TextUtils.equals(extensionVersion, context.getString(R.string.status_shortener_service_interface_version))) { throw ExtensionVersionMismatchException() } } } catch (e: ExtensionVersionMismatchException) { throw ShortenException(context.getString(R.string.shortener_version_incompatible)) } catch (e: AbsServiceInterface.CheckServiceException) { throw ShortenException(e) } return shortener } @Throws(UploaderNotFoundException::class, UploadException::class) private fun getMediaUploader(app: TwidereApplication): MediaUploaderInterface? { val uploaderComponent = preferences.getString(KEY_MEDIA_UPLOADER, null) if (ComponentPickerPreference.isNoneValue(uploaderComponent)) return null val uploader = MediaUploaderInterface.getInstance(app, uploaderComponent) ?: throw UploaderNotFoundException(context.getString(R.string.error_message_media_uploader_not_found)) try { uploader.checkService { metaData -> if (metaData == null) throw ExtensionVersionMismatchException() val extensionVersion = metaData.getString(METADATA_KEY_EXTENSION_VERSION_MEDIA_UPLOADER) if (!TextUtils.equals(extensionVersion, context.getString(R.string.media_uploader_service_interface_version))) { throw ExtensionVersionMismatchException() } } } catch (e: AbsServiceInterface.CheckServiceException) { if (e is ExtensionVersionMismatchException) { throw UploadException(context.getString(R.string.uploader_version_incompatible), e) } throw UploadException(e) } return uploader } private fun isDuplicate(exception: Exception): Boolean { return exception is MicroBlogException && exception.errorCode == ErrorInfo.STATUS_IS_DUPLICATE } class PendingStatusUpdate internal constructor(val length: Int, defaultText: String) { internal constructor(statusUpdate: ParcelableStatusUpdate) : this(statusUpdate.accounts.size, statusUpdate.text) var sharedMediaIds: Array? = null var sharedMediaOwners: Array? = null val overrideTexts: Array = Array(length) { defaultText } val mediaIds: Array?> = arrayOfNulls(length) val mediaUploadResults: Array = arrayOfNulls(length) val statusShortenResults: Array = arrayOfNulls(length) val deleteOnSuccess: ArrayList = arrayListOf() val deleteAlways: ArrayList = arrayListOf() } class UpdateStatusResult { val statuses: Array val exceptions: Array val accountTypes: Array val exception: UpdateStatusException? val draftId: Long val succeed: Boolean get() = exception == null && exceptions.none { it != null } constructor(count: Int, draftId: Long) { this.statuses = arrayOfNulls(count) this.exceptions = arrayOfNulls(count) this.accountTypes = arrayOfNulls(count) this.exception = null this.draftId = draftId } constructor(exception: UpdateStatusException, draftId: Long) { this.exception = exception this.statuses = arrayOfNulls(0) this.exceptions = arrayOfNulls(0) this.accountTypes = arrayOfNulls(0) this.draftId = draftId } } open class UpdateStatusException : Exception { protected constructor() : super() protected constructor(detailMessage: String, throwable: Throwable) : super(detailMessage, throwable) protected constructor(throwable: Throwable) : super(throwable) protected constructor(message: String) : super(message) } class UploaderNotFoundException(message: String) : UpdateStatusException(message) class SchedulerNotFoundException(message: String) : UpdateStatusException(message) class UploadException : UpdateStatusException { var deleteAlways: List? = null constructor() : super() constructor(detailMessage: String, throwable: Throwable) : super(detailMessage, throwable) constructor(throwable: Throwable) : super(throwable) constructor(message: String) : super(message) } class ExtensionVersionMismatchException : AbsServiceInterface.CheckServiceException() class ShortenerNotFoundException : UpdateStatusException() class ShortenException : UpdateStatusException { constructor() : super() constructor(detailMessage: String, throwable: Throwable) : super(detailMessage, throwable) constructor(throwable: Throwable) : super(throwable) constructor(message: String) : super(message) } interface StateCallback : UploadCallback { @WorkerThread fun onShorteningStatus() @WorkerThread fun onUpdatingStatus() @UiThread fun afterExecute(result: UpdateStatusResult) @UiThread fun beforeExecute() } interface UploadCallback { @WorkerThread fun onStartUploadingMedia() @WorkerThread fun onUploadingProgressChanged(index: Int, current: Long, total: Long) } data class SizeLimit( val image: AccountExtras.ImageLimit, val video: AccountExtras.VideoLimit ) data class MediaStreamBody( val body: Body, val geometry: Point?, val deleteOnSuccess: List?, val deleteAlways: List? ) : Closeable { override fun close() { body.close() } } interface MediaDeletionItem { fun delete(context: Context): Boolean } data class UriMediaDeletionItem(val uri: Uri) : MediaDeletionItem { override fun delete(context: Context): Boolean { return Utils.deleteMedia(context, uri) } } data class FileMediaDeletionItem(val file: File) : MediaDeletionItem { override fun delete(context: Context): Boolean { return file.delete() } } data class SharedMediaUploadResult( val ids: Array, val deleteOnSuccess: List, val deleteAlways: List ) companion object { private val BULK_SIZE = 512 * 1024// 512 Kib @Throws(UploadException::class) fun uploadMicroBlogMediaShared(context: Context, upload: TwitterUpload, account: AccountDetails, media: Array, mediaCategory: String? = null, ownerIds: Array?, chucked: Boolean, callback: UploadCallback?): SharedMediaUploadResult { val deleteOnSuccess = ArrayList() val deleteAlways = ArrayList() val mediaIds = media.mapIndexedToArray { index, media -> val resp: MediaUploadResponse //noinspection TryWithIdenticalCatches var body: MediaStreamBody? = null try { val sizeLimit = account.mediaSizeLimit body = getBodyFromMedia(context, media, sizeLimit, chucked, ContentLengthInputStream.ReadListener { length, position -> callback?.onUploadingProgressChanged(index, position, length) }) if (chucked) { resp = uploadMediaChucked(upload, body.body, mediaCategory, ownerIds) } else { resp = upload.uploadMedia(body.body, ownerIds) } } catch (e: IOException) { throw UploadException(e).apply { this.deleteAlways = deleteAlways } } catch (e: MicroBlogException) { throw UploadException(e).apply { this.deleteAlways = deleteAlways } } finally { body?.close() } body?.deleteOnSuccess?.addAllTo(deleteOnSuccess) body?.deleteAlways?.addAllTo(deleteAlways) if (media.alt_text?.isNotEmpty() ?: false) { try { upload.createMetadata(NewMediaMetadata(resp.id, media.alt_text)) } catch (e: MicroBlogException) { // Ignore } } return@mapIndexedToArray resp.id } return SharedMediaUploadResult(mediaIds, deleteOnSuccess, deleteAlways) } @Throws(UploadException::class) fun uploadMastodonMedia(context: Context, mastodon: Mastodon, account: AccountDetails, media: Array, chucked: Boolean, callback: UploadCallback?): SharedMediaUploadResult { val deleteOnSuccess = ArrayList() val deleteAlways = ArrayList() val mediaIds = media.mapIndexedToArray { index, media -> val resp: Attachment //noinspection TryWithIdenticalCatches var body: MediaStreamBody? = null try { val sizeLimit = account.mediaSizeLimit body = getBodyFromMedia(context, media, sizeLimit, chucked, ContentLengthInputStream.ReadListener { length, position -> callback?.onUploadingProgressChanged(index, position, length) }) resp = mastodon.uploadMediaAttachment(body.body) } catch (e: IOException) { throw UploadException(e).apply { this.deleteAlways = deleteAlways } } catch (e: MicroBlogException) { throw UploadException(e).apply { this.deleteAlways = deleteAlways } } finally { body?.close() } body?.deleteOnSuccess?.addAllTo(deleteOnSuccess) body?.deleteAlways?.addAllTo(deleteAlways) return@mapIndexedToArray resp.id } return SharedMediaUploadResult(mediaIds, deleteOnSuccess, deleteAlways) } @Throws(IOException::class) fun getBodyFromMedia(context: Context, media: ParcelableMediaUpdate, sizeLimit: SizeLimit? = null, chucked: Boolean, readListener: ContentLengthInputStream.ReadListener): MediaStreamBody { return getBodyFromMedia(context, Uri.parse(media.uri), media.type, media.delete_always, media.delete_on_success, sizeLimit, chucked, readListener) } @Throws(IOException::class) fun getBodyFromMedia(context: Context, mediaUri: Uri, @ParcelableMedia.Type type: Int, isDeleteAlways: Boolean, isDeleteOnSuccess: Boolean, sizeLimit: SizeLimit? = null, chucked: Boolean, readListener: ContentLengthInputStream.ReadListener?): MediaStreamBody { val deleteOnSuccessList: MutableList = mutableListOf() val deleteAlwaysList: MutableList = mutableListOf() val deletionItem = UriMediaDeletionItem(mediaUri) if (isDeleteAlways) { deleteAlwaysList.add(deletionItem) } else if (isDeleteOnSuccess) { deleteOnSuccessList.add(deletionItem) } try { val resolver = context.contentResolver val mediaType = resolver.getType(mediaUri) ?: run { return@run mediaUri.lastPathSegment?.substringAfterLast(".")?.let { ext -> MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) } } val data = when (type) { ParcelableMedia.Type.IMAGE -> imageStream(context, resolver, mediaUri, mediaType, sizeLimit?.image) ParcelableMedia.Type.VIDEO -> videoStream(context, resolver, mediaUri, mediaType, sizeLimit?.video, chucked) else -> null } val cis = data?.stream ?: run { val st = resolver.openInputStream(mediaUri) ?: throw FileNotFoundException(mediaUri.toString()) val length = st.available().toLong() return@run ContentLengthInputStream(st, length) } cis.setReadListener(readListener) val mimeType = data?.type ?: mediaType ?: "application/octet-stream" val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: ".bin" val fileName = mediaUri.lastPathSegment?.substringBeforeLast(".") ?: "attachment" val body = FileBody(cis, "$fileName.$extension", cis.length(), ContentType.parse(mimeType)) data?.deleteOnSuccess?.addAllTo(deleteOnSuccessList) data?.deleteAlways?.addAllTo(deleteAlwaysList) return MediaStreamBody(body, data?.geometry, deleteOnSuccessList, deleteAlwaysList) } finally { if (isDeleteAlways) { deleteAlwaysList.forEach { it.delete(context) } } } } @Throws(IOException::class, MicroBlogException::class) private fun uploadMediaChucked(upload: TwitterUpload, body: Body, mediaCategory: String? = null, ownerIds: Array?): MediaUploadResponse { val mediaType = body.contentType().contentType val length = body.length() val stream = body.stream() var response = upload.initUploadMedia(mediaType, length, mediaCategory, ownerIds) val segments = if (length == 0L) 0 else (length / BULK_SIZE + 1).toInt() for (segmentIndex in 0..segments - 1) { val currentBulkSize = Math.min(BULK_SIZE.toLong(), length - segmentIndex * BULK_SIZE).toInt() val bulk = SimpleBody(ContentType.OCTET_STREAM, null, currentBulkSize.toLong(), stream) upload.appendUploadMedia(response.id, segmentIndex, bulk) } response = upload.finalizeUploadMedia(response.id) var info: MediaUploadResponse.ProcessingInfo? = response.processingInfo while (info != null && shouldWaitForProcess(info)) { val checkAfterSecs = info.checkAfterSecs if (checkAfterSecs <= 0) { break } try { Thread.sleep(TimeUnit.SECONDS.toMillis(checkAfterSecs)) } catch (e: InterruptedException) { break } response = upload.getUploadMediaStatus(response.id) info = response.processingInfo } if (info != null && MediaUploadResponse.ProcessingInfo.State.FAILED == info.state) { val exception = MicroBlogException() val errorInfo = info.error if (errorInfo != null) { exception.errors = arrayOf(errorInfo) } throw exception } return response } private fun shouldWaitForProcess(info: MediaUploadResponse.ProcessingInfo): Boolean { when (info.state) { MediaUploadResponse.ProcessingInfo.State.PENDING, MediaUploadResponse.ProcessingInfo.State.IN_PROGRESS -> return true else -> return false } } private fun imageStream( context: Context, resolver: ContentResolver, mediaUri: Uri, defaultType: String?, imageLimit: AccountExtras.ImageLimit? ): MediaStreamData? { var mediaType = defaultType val o = BitmapFactory.Options() o.inJustDecodeBounds = true BitmapFactoryUtils.decodeUri(resolver, mediaUri, opts = o) // Try to use decoded media type if (o.outMimeType != null) { mediaType = o.outMimeType } // Skip if media is GIF if (o.outWidth <= 0 || o.outHeight <= 0 || mediaType == "image/gif") { return null } if (imageLimit == null || imageLimit.checkGeomentry(o.outWidth, o.outHeight)) return null o.inSampleSize = BitmapUtils.calculateInSampleSize(o.outWidth, o.outHeight, imageLimit.maxWidth, imageLimit.maxHeight) o.inJustDecodeBounds = false // Do actual image decoding val bitmap = context.contentResolver.openInputStream(mediaUri).use { BitmapFactory.decodeStream(it, null, o) } ?: return null val size = Point(bitmap.width, bitmap.height) val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mediaType) val tempFile = File.createTempFile("twidere__scaled_image_", ".$ext", context.cacheDir) when (mediaType) { "image/png", "image/x-png", "image/webp", "image-x-webp" -> { tempFile.outputStream().use { os -> bitmap.compress(Bitmap.CompressFormat.PNG, 0, os) } } "image/jpeg" -> { val origExif = context.contentResolver.openInputStream(mediaUri) .use(::ExifInterface) tempFile.outputStream().use { os -> bitmap.compress(Bitmap.CompressFormat.JPEG, 85, os) } val orientation = origExif.getAttribute(ExifInterface.TAG_ORIENTATION) if (orientation != null) { ExifInterface(tempFile.absolutePath).apply { setAttribute(ExifInterface.TAG_ORIENTATION, orientation) saveAttributes() } } } else -> { tempFile.outputStream().use { os -> bitmap.compress(Bitmap.CompressFormat.JPEG, 85, os) } } } return MediaStreamData(ContentLengthInputStream(tempFile), mediaType, size, null, listOf(FileMediaDeletionItem(tempFile))) } private fun videoStream( context: Context, resolver: ContentResolver, mediaUri: Uri, defaultType: String?, videoLimit: AccountExtras.VideoLimit?, chucked: Boolean ): MediaStreamData? { var mediaType = defaultType val geometry = Point() var duration = -1L var framerate = -1.0 var size = -1L // TODO only transcode video if needed, use `MediaMetadataRetriever` val retriever = MediaMetadataRetriever() try { retriever.setDataSource(context, mediaUri) val extractedMimeType = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) if (extractedMimeType != null) { mediaType = extractedMimeType } geometry.x = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH).toIntOr(-1) geometry.y = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT).toIntOr(-1) duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION).toLongOr(-1) framerate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE).toDoubleOr(-1.0) size = resolver.openFileDescriptor(mediaUri, "r").use { it.statSize } } catch (e: Exception) { DebugLog.w(LOGTAG, "Unable to retrieve video info", e) } finally { retriever.releaseSafe() } if (videoLimit != null) { if (geometry.x > 0 && geometry.y > 0 && videoLimit.checkGeometry(geometry.x, geometry.y) && framerate > 0 && videoLimit.checkFrameRate(framerate) && size > 0 && videoLimit.checkSize(size, chucked)) { // Size valid, upload directly DebugLog.d(LOGTAG, "Upload video directly") return null } if (!videoLimit.checkMinDuration(duration, chucked)) { throw UploadException(context.getString(R.string.message_video_too_short)) } if (!videoLimit.checkMaxDuration(duration, chucked)) { throw UploadException(context.getString(R.string.message_video_too_long)) } } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) { // Go get a new phone return null } DebugLog.d(LOGTAG, "Transcoding video") val ext = mediaUri.lastPathSegment.substringAfterLast(".") val strategy = MediaFormatStrategyPresets.createAndroid720pStrategy() val listener = object : MediaTranscoder.Listener { override fun onTranscodeFailed(exception: Exception?) { } override fun onTranscodeCompleted() { } override fun onTranscodeProgress(progress: Double) { } override fun onTranscodeCanceled() { } } val pfd = resolver.openFileDescriptor(mediaUri, "r") val tempFile = File.createTempFile("twidere__encoded_video_", ".$ext", context.cacheDir) val future = MediaTranscoder.getInstance().transcodeVideo(pfd.fileDescriptor, tempFile.absolutePath, strategy, listener) try { future.get() } catch (e: Exception) { DebugLog.w(LOGTAG, "Error transcoding video, try upload directly", e) tempFile.delete() return null } return MediaStreamData(ContentLengthInputStream(tempFile.inputStream(), tempFile.length()), mediaType, geometry, null, listOf(FileMediaDeletionItem(tempFile))) } internal class MediaStreamData( val stream: ContentLengthInputStream?, val type: String?, val geometry: Point?, val deleteOnSuccess: List?, val deleteAlways: List? ) inline fun createDraft(@Draft.Action action: String, config: Draft.() -> Unit): Draft { val draft = Draft() draft.action_type = action draft.timestamp = System.currentTimeMillis() config(draft) return draft } fun saveDraft(context: Context, @Draft.Action action: String, config: Draft.() -> Unit): Long { val draft = createDraft(action, config) val resolver = context.contentResolver val creator = ObjectCursor.valuesCreatorFrom(Draft::class.java) val draftUri = resolver.insert(Drafts.CONTENT_URI, creator.create(draft)) ?: return -1 return draftUri.lastPathSegment.toLongOr(-1) } fun deleteDraft(context: Context, id: Long) { val where = Expression.equals(Drafts._ID, id).sql context.contentResolver.delete(Drafts.CONTENT_URI, where, null) } fun AccountExtras.ImageLimit.checkGeomentry(width: Int, height: Int): Boolean { if (this.maxWidth <= 0 || this.maxHeight <= 0) return true return (width <= this.maxWidth && height <= this.maxHeight) || (height <= this.maxWidth && width <= this.maxHeight) } } }