Twidere-App-Android-Twitter.../twidere/src/main/kotlin/org/mariotaku/twidere/service/LengthyOperationsService.kt

451 lines
19 KiB
Kotlin

/*
* Twidere - Twitter client for Android
*
* Copyright (C) 2012-2014 Mariotaku Lee <mariotaku.lee@gmail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.mariotaku.twidere.service
import android.accounts.AccountManager
import android.annotation.SuppressLint
import android.app.Notification
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.support.annotation.UiThread
import android.support.annotation.WorkerThread
import android.support.v4.app.NotificationCompat
import android.text.TextUtils
import android.util.Log
import android.widget.Toast
import nl.komponents.kovenant.task
import nl.komponents.kovenant.ui.successUi
import org.mariotaku.abstask.library.AbstractTask
import org.mariotaku.abstask.library.ManualTaskStarter
import org.mariotaku.kpreferences.get
import org.mariotaku.ktextension.getNullableTypedArrayExtra
import org.mariotaku.ktextension.toLongOr
import org.mariotaku.ktextension.useCursor
import org.mariotaku.library.objectcursor.ObjectCursor
import org.mariotaku.microblog.library.MicroBlogException
import org.mariotaku.microblog.library.twitter.TwitterUpload
import org.mariotaku.microblog.library.twitter.model.MediaUploadResponse
import org.mariotaku.microblog.library.twitter.model.MediaUploadResponse.ProcessingInfo
import org.mariotaku.restfu.http.ContentType
import org.mariotaku.restfu.http.mime.Body
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.constant.refreshAfterTweetKey
import org.mariotaku.twidere.extension.getErrorMessage
import org.mariotaku.twidere.extension.model.notificationBuilder
import org.mariotaku.twidere.extension.withAppendedPath
import org.mariotaku.twidere.model.*
import org.mariotaku.twidere.model.draft.SendDirectMessageActionExtras
import org.mariotaku.twidere.model.draft.StatusObjectActionExtras
import org.mariotaku.twidere.model.notification.NotificationChannelSpec
import org.mariotaku.twidere.model.schedule.ScheduleInfo
import org.mariotaku.twidere.model.util.AccountUtils
import org.mariotaku.twidere.model.util.ParcelableStatusUpdateUtils
import org.mariotaku.twidere.provider.TwidereDataStore.Drafts
import org.mariotaku.twidere.task.CreateFavoriteTask
import org.mariotaku.twidere.task.RetweetStatusTask
import org.mariotaku.twidere.task.twitter.UpdateStatusTask
import org.mariotaku.twidere.task.twitter.message.SendMessageTask
import org.mariotaku.twidere.util.deleteDrafts
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Intent service for lengthy operations like update status/send DM.
*/
class LengthyOperationsService : BaseIntentService("lengthy_operations") {
private val handler: Handler by lazy { Handler(Looper.getMainLooper()) }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
return Service.START_STICKY
}
override fun onHandleIntent(intent: Intent?) {
if (intent == null) return
val action = intent.action ?: return
when (action) {
INTENT_ACTION_UPDATE_STATUS -> {
handleUpdateStatusIntent(intent)
}
INTENT_ACTION_SEND_DIRECT_MESSAGE -> {
handleSendDirectMessageIntent(intent)
}
INTENT_ACTION_DISCARD_DRAFT -> {
handleDiscardDraftIntent(intent)
}
INTENT_ACTION_SEND_DRAFT -> {
handleSendDraftIntent(intent)
}
}
}
private fun showToast(e: Exception, longMessage: Boolean) {
handler.post {
Toast.makeText(this, e.getErrorMessage(this), if (longMessage) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show()
}
}
private fun showToast(message: Int, longMessage: Boolean) {
handler.post { Toast.makeText(this, message, if (longMessage) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show() }
}
private fun handleSendDraftIntent(intent: Intent) {
val uri = intent.data ?: return
notificationManager.cancel(uri.toString(), NOTIFICATION_ID_DRAFTS)
val draftId = uri.lastPathSegment.toLongOr(-1L)
if (draftId == -1L) return
val where = Expression.equals(Drafts._ID, draftId)
@SuppressLint("Recycle")
val draft: Draft = contentResolver.query(Drafts.CONTENT_URI, Drafts.COLUMNS, where.sql, null, null)?.useCursor {
val i = ObjectCursor.indicesFrom(it, Draft::class.java)
if (!it.moveToFirst()) return@useCursor null
return@useCursor i.newObject(it)
} ?: return
contentResolver.delete(Drafts.CONTENT_URI, where.sql, null)
if (TextUtils.isEmpty(draft.action_type)) {
draft.action_type = Draft.Action.UPDATE_STATUS
}
when (draft.action_type) {
Draft.Action.UPDATE_STATUS_COMPAT_1, Draft.Action.UPDATE_STATUS_COMPAT_2,
Draft.Action.UPDATE_STATUS, Draft.Action.REPLY, Draft.Action.QUOTE -> {
updateStatuses(arrayOf(ParcelableStatusUpdateUtils.fromDraftItem(this, draft)))
}
Draft.Action.SEND_DIRECT_MESSAGE_COMPAT, Draft.Action.SEND_DIRECT_MESSAGE -> {
val extras = draft.action_extras as? SendDirectMessageActionExtras ?: return
val message = ParcelableNewMessage().apply {
this.account = draft.account_keys?.firstOrNull()?.let { key ->
val am = AccountManager.get(this@LengthyOperationsService)
return@let AccountUtils.getAccountDetails(am, key, true)
}
this.text = draft.text
this.media = draft.media
this.recipient_ids = extras.recipientIds
this.conversation_id = extras.conversationId
}
sendMessage(message)
}
Draft.Action.FAVORITE -> {
performStatusAction(draft) { accountKey, status ->
CreateFavoriteTask(this, accountKey, status)
}
}
Draft.Action.RETWEET -> {
performStatusAction(draft) { accountKey, status ->
RetweetStatusTask(this, accountKey, status)
}
}
}
}
@SuppressLint("Recycle")
private fun handleDiscardDraftIntent(intent: Intent) {
val data = intent.data ?: return
task {
if (deleteDrafts(this, longArrayOf(data.lastPathSegment.toLongOr(-1L))) < 1) {
throw IOException()
}
return@task data
}.successUi { uri ->
notificationManager.cancel(uri.toString(), NOTIFICATION_ID_DRAFTS)
}
}
private fun handleSendDirectMessageIntent(intent: Intent) {
val message = intent.getParcelableExtra<ParcelableNewMessage>(EXTRA_MESSAGE) ?: return
sendMessage(message)
}
private fun sendMessage(message: ParcelableNewMessage) {
val title = getString(R.string.sending_direct_message)
val builder = NotificationChannelSpec.backgroundProgresses.notificationBuilder(this)
builder.setSmallIcon(R.drawable.ic_stat_send)
builder.setProgress(100, 0, true)
builder.setTicker(title)
builder.setContentTitle(title)
builder.setContentText(message.text)
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setOngoing(true)
val notification = builder.build()
startForeground(NOTIFICATION_ID_SEND_DIRECT_MESSAGE, notification)
val task = SendMessageTask(this)
task.params = message
invokeBeforeExecute(task)
val result = ManualTaskStarter.invokeExecute(task)
invokeAfterExecute(task, result)
if (result.hasData()) {
showToast(R.string.message_direct_message_sent, false)
} else {
UpdateStatusTask.saveDraft(this, Draft.Action.SEND_DIRECT_MESSAGE) {
account_keys = arrayOf(message.account.key)
text = message.text
media = message.media
action_extras = SendDirectMessageActionExtras().apply {
recipientIds = message.recipient_ids
conversationId = message.conversation_id
}
}
val exception = result.exception
if (exception != null) {
showToast(exception, true)
}
}
stopForeground(false)
notificationManager.cancel(NOTIFICATION_ID_SEND_DIRECT_MESSAGE)
}
private fun handleUpdateStatusIntent(intent: Intent) {
val status = intent.getParcelableExtra<ParcelableStatusUpdate>(EXTRA_STATUS)
val statusParcelables = intent.getNullableTypedArrayExtra<ParcelableStatusUpdate>(EXTRA_STATUSES)
val scheduleInfo = intent.getParcelableExtra<ScheduleInfo>(EXTRA_SCHEDULE_INFO)
val statuses: Array<ParcelableStatusUpdate>
if (statusParcelables != null) {
statuses = statusParcelables
} else if (status != null) {
statuses = arrayOf(status)
} else
return
@Draft.Action
val actionType = intent.getStringExtra(EXTRA_ACTION)
statuses.forEach { it.draft_action = actionType }
updateStatuses(statuses, scheduleInfo)
}
private fun updateStatuses(statuses: Array<ParcelableStatusUpdate>, scheduleInfo: ScheduleInfo? = null) {
val context = this
val builder = NotificationChannelSpec.backgroundProgresses.notificationBuilder(context)
startForeground(NOTIFICATION_ID_UPDATE_STATUS, updateUpdateStatusNotification(context,
builder, 0, null))
for (item in statuses) {
val task = UpdateStatusTask(context, object : UpdateStatusTask.StateCallback {
@WorkerThread
override fun onStartUploadingMedia() {
startForeground(NOTIFICATION_ID_UPDATE_STATUS, updateUpdateStatusNotification(context,
builder, 0, item))
}
@WorkerThread
override fun onUploadingProgressChanged(index: Int, current: Long, total: Long) {
val progress = (current * 100 / total).toInt()
startForeground(NOTIFICATION_ID_UPDATE_STATUS, updateUpdateStatusNotification(context,
builder, progress, item))
}
@WorkerThread
override fun onShorteningStatus() {
startForeground(NOTIFICATION_ID_UPDATE_STATUS, updateUpdateStatusNotification(context,
builder, 0, item))
}
@WorkerThread
override fun onUpdatingStatus() {
startForeground(NOTIFICATION_ID_UPDATE_STATUS, updateUpdateStatusNotification(context,
builder, 0, item))
}
@UiThread
override fun afterExecute(result: UpdateStatusTask.UpdateStatusResult) {
var failed = false
val exception = result.exception
val exceptions = result.exceptions
if (exception != null) {
val cause = exception.cause
if (cause is MicroBlogException) {
Toast.makeText(context, cause.errors?.firstOrNull()?.message ?: cause.message,
Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, exception.message, Toast.LENGTH_SHORT).show()
}
failed = true
Log.w(LOGTAG, exception)
} else for (e in exceptions) {
if (e != null) {
// Show error
val errorMessage = e.getErrorMessage(context)
Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
failed = true
break
}
}
if (!failed) {
if (scheduleInfo != null) {
Toast.makeText(context, R.string.message_toast_status_scheduled,
Toast.LENGTH_SHORT).show()
} else if (item.repost_status_id != null ||
item.draft_action == Draft.Action.QUOTE) {
Toast.makeText(context, R.string.message_toast_status_retweeted,
Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, R.string.message_toast_status_updated,
Toast.LENGTH_SHORT).show()
}
}
}
override fun beforeExecute() {
}
})
task.callback = this
task.params = Pair(item, scheduleInfo)
invokeBeforeExecute(task)
val result = ManualTaskStarter.invokeExecute(task)
invokeAfterExecute(task, result)
if (!result.succeed) {
contentResolver.insert(Drafts.CONTENT_URI_NOTIFICATIONS.withAppendedPath(result.draftId.toString()), null)
}
}
if (preferences[refreshAfterTweetKey]) {
handler.post { twitterWrapper.refreshAll() }
}
stopForeground(false)
notificationManager.cancel(NOTIFICATION_ID_UPDATE_STATUS)
}
@Throws(IOException::class, MicroBlogException::class)
private fun uploadMedia(upload: TwitterUpload, body: Body): MediaUploadResponse {
val mediaType = body.contentType().contentType
val length = body.length()
val stream = body.stream()
var response = upload.initUploadMedia(mediaType, length, null, null)
val segments = if (length == 0L) 0 else (length / BULK_SIZE + 1).toInt()
for (segmentIndex in 0 until segments) {
val currentBulkSize = Math.min(BULK_SIZE, 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)
run {
var info: 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
}
}
val info = response.processingInfo
if (info != null && 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: ProcessingInfo): Boolean {
when (info.state) {
ProcessingInfo.State.PENDING, ProcessingInfo.State.IN_PROGRESS -> return true
else -> return false
}
}
private fun <T> performStatusAction(draft: Draft, action: (accountKey: UserKey, status: ParcelableStatus) -> AbstractTask<*, T, *>): Boolean {
val accountKey = draft.account_keys?.firstOrNull() ?: return false
val status = (draft.action_extras as? StatusObjectActionExtras)?.status ?: return false
val task = action(accountKey, status)
invokeBeforeExecute(task)
val result = ManualTaskStarter.invokeExecute(task)
invokeAfterExecute(task, result)
return true
}
private fun invokeBeforeExecute(task: AbstractTask<*, *, *>) {
handler.post { ManualTaskStarter.invokeBeforeExecute(task) }
}
private fun <T> invokeAfterExecute(task: AbstractTask<*, T, *>, result: T) {
handler.post { ManualTaskStarter.invokeAfterExecute(task, result) }
}
companion object {
private val BULK_SIZE = (128 * 1024).toLong() // 128KiB
private fun updateSendDirectMessageNotification(context: Context,
builder: NotificationCompat.Builder,
progress: Int, message: String?): Notification {
builder.setContentTitle(context.getString(R.string.sending_direct_message))
if (message != null) {
builder.setContentText(message)
}
builder.setSmallIcon(R.drawable.ic_stat_send)
builder.setProgress(100, progress, progress >= 100 || progress <= 0)
builder.setOngoing(true)
return builder.build()
}
private fun updateUpdateStatusNotification(context: Context,
builder: NotificationCompat.Builder,
progress: Int,
status: ParcelableStatusUpdate?): Notification {
builder.setContentTitle(context.getString(R.string.updating_status_notification))
if (status != null) {
builder.setContentText(status.text)
}
builder.setSmallIcon(R.drawable.ic_stat_send)
builder.setProgress(100, progress, progress >= 100 || progress <= 0)
builder.setOngoing(true)
return builder.build()
}
fun updateStatusesAsync(context: Context, @Draft.Action action: String,
vararg statuses: ParcelableStatusUpdate, scheduleInfo: ScheduleInfo? = null) {
val intent = Intent(context, LengthyOperationsService::class.java)
intent.action = INTENT_ACTION_UPDATE_STATUS
intent.putExtra(EXTRA_STATUSES, statuses)
intent.putExtra(EXTRA_SCHEDULE_INFO, scheduleInfo)
intent.putExtra(EXTRA_ACTION, action)
context.startService(intent)
}
fun sendMessageAsync(context: Context, message: ParcelableNewMessage) {
val intent = Intent(context, LengthyOperationsService::class.java)
intent.action = INTENT_ACTION_SEND_DIRECT_MESSAGE
intent.putExtra(EXTRA_MESSAGE, message)
context.startService(intent)
}
}
}