Pixelcat-App-Android/app/src/main/kotlin/at/connyduck/pixelcat/components/compose/SendStatusService.kt

314 lines
11 KiB
Kotlin
Raw Normal View History

2020-06-17 19:07:47 +02:00
/*
* Copyright (C) 2020 Conny Duck
*
* This file is part of Pixelcat.
*
* Pixelcat 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.
*
* Pixelcat 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 <https://www.gnu.org/licenses/>.
*/
2020-06-12 15:44:45 +02:00
package at.connyduck.pixelcat.components.compose
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.Parcelable
import android.webkit.MimeTypeMap
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.util.extension.getColorForAttr
2020-06-17 13:23:10 +02:00
import at.connyduck.pixelcat.components.util.getMimeType
2020-06-12 15:44:45 +02:00
import at.connyduck.pixelcat.db.AccountManager
import at.connyduck.pixelcat.model.NewStatus
import at.connyduck.pixelcat.network.FediverseApi
import dagger.android.DaggerService
2020-06-12 19:58:15 +02:00
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
2020-06-12 15:44:45 +02:00
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import java.io.IOException
2020-06-12 19:58:15 +02:00
import java.util.Timer
import java.util.TimerTask
import java.util.UUID
2020-06-12 15:44:45 +02:00
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
class SendStatusService : DaggerService(), CoroutineScope {
@Inject
lateinit var api: FediverseApi
@Inject
lateinit var accountManager: AccountManager
private val statusesToSend = ConcurrentHashMap<Int, StatusToSend>()
private val sendJobs = ConcurrentHashMap<Int, Job>()
private val timer = Timer()
private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.hasExtra(KEY_STATUS)) {
val tootToSend = intent.getParcelableExtra<StatusToSend>(KEY_STATUS)
2020-06-12 19:58:15 +02:00
?: throw IllegalStateException("SendTootService started without $KEY_STATUS extra")
2020-06-12 15:44:45 +02:00
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_status_notification_channel_name), NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(channel)
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
2020-06-12 19:58:15 +02:00
.setSmallIcon(R.drawable.ic_cat)
.setContentTitle(getString(R.string.send_status_notification_title))
.setContentText(tootToSend.text)
.setProgress(1, 0, true)
.setOngoing(true)
.setColor(getColorForAttr(android.R.attr.colorPrimary))
.addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId))
2020-06-12 15:44:45 +02:00
if (statusesToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
startForeground(sendingNotificationId, builder.build())
} else {
notificationManager.notify(sendingNotificationId, builder.build())
}
statusesToSend[sendingNotificationId] = tootToSend
sendStatus(sendingNotificationId--)
} else {
if (intent.hasExtra(KEY_CANCEL)) {
cancelSending(intent.getIntExtra(KEY_CANCEL, 0))
}
}
return START_NOT_STICKY
}
private fun sendStatus(id: Int) {
// when tootToSend == null, sending has been canceled
val statusToSend = statusesToSend[id] ?: return
// when account == null, user has logged out, cancel sending
val account = accountManager.getAccountById(statusToSend.accountId)
if (account == null) {
statusesToSend.remove(id)
notificationManager.cancel(id)
stopSelfWhenDone()
return
}
statusToSend.retries++
launch {
2020-06-17 13:23:10 +02:00
val mediaIds = statusToSend.mediaUris.map { mediaPath ->
2020-06-12 15:44:45 +02:00
2020-06-17 13:23:10 +02:00
val mimeType = getMimeType(mediaPath)
if (mimeType == null) {
// unrecoverable error
onError(UnrecoverableError(), id)
return@launch
2020-06-12 15:44:45 +02:00
}
2020-06-17 13:23:10 +02:00
val fileExtension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
val filePart = File(mediaPath).asRequestBody(mimeType.toMediaType())
2020-06-12 15:44:45 +02:00
2020-06-17 13:23:10 +02:00
val body = MultipartBody.Part.createFormData("file", "test.$fileExtension", filePart)
2020-06-12 15:44:45 +02:00
2020-06-12 19:58:15 +02:00
api.uploadMedia(body).fold(
{ attachment ->
attachment.id
},
2020-06-17 13:23:10 +02:00
{ error ->
onError(error, id)
return@launch
2020-06-12 19:58:15 +02:00
}
)
2020-06-12 15:44:45 +02:00
}
val newStatus = NewStatus(
status = statusToSend.text,
inReplyToId = null,
visibility = statusToSend.visibility,
sensitive = statusToSend.sensitive,
mediaIds = mediaIds
)
api.createStatus(
"Bearer " + account.auth.accessToken,
account.domain,
statusToSend.idempotencyKey,
newStatus
).fold(
2020-06-12 19:58:15 +02:00
{
statusesToSend.remove(id)
2020-06-17 13:23:10 +02:00
stopSelfWhenDone()
2020-06-12 19:58:15 +02:00
},
{
2020-06-17 13:23:10 +02:00
onError(it, id)
2020-06-12 15:44:45 +02:00
}
2020-06-12 19:58:15 +02:00
)
2020-06-12 15:44:45 +02:00
}.apply {
sendJobs[id] = this
}
}
2020-06-17 13:23:10 +02:00
private fun onError(error: Throwable, id: Int) {
val statusToSend = statusesToSend[id] ?: return
when (error) {
is IOException -> {
// possibly a network problem, we might still have a chance sending the status
2020-06-17 13:23:10 +02:00
var backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong())
if (backoff > MAX_RETRY_INTERVAL) {
backoff = MAX_RETRY_INTERVAL
}
timer.schedule(
object : TimerTask() {
override fun run() {
sendStatus(id)
}
},
backoff
)
}
else -> {
// the server refused to accept the status, save toot & show error message
// TODO saveToDrafts
val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_cat)
.setContentTitle(getString(R.string.send_status_notification_error_title))
// .setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(getColorForAttr(android.R.attr.colorPrimary))
notificationManager.cancel(id)
notificationManager.notify(errorNotificationId--, builder.build())
}
2020-06-17 13:23:10 +02:00
}
}
2020-06-12 15:44:45 +02:00
private fun stopSelfWhenDone() {
if (statusesToSend.isEmpty()) {
coroutineContext.cancel()
ServiceCompat.stopForeground(this@SendStatusService, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun cancelSending(id: Int) {
val statusToCancel = statusesToSend.remove(id)
if (statusToCancel != null) {
val sendCall = sendJobs.remove(id)
sendCall?.cancel()
2020-06-12 19:58:15 +02:00
// saveTootToDrafts(tootToCancel)
2020-06-12 15:44:45 +02:00
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
2020-06-12 19:58:15 +02:00
.setSmallIcon(R.drawable.ic_cat)
.setContentTitle(getString(R.string.send_status_notification_cancel_title))
2020-06-12 15:44:45 +02:00
// .setContentText(getString(R.string.send_toot_notification_saved_content))
2020-06-12 19:58:15 +02:00
.setColor(getColorForAttr(android.R.attr.colorPrimary))
2020-06-12 15:44:45 +02:00
notificationManager.notify(id, builder.build())
2020-06-12 19:58:15 +02:00
timer.schedule(
object : TimerTask() {
override fun run() {
notificationManager.cancel(id)
stopSelfWhenDone()
}
},
5000
)
2020-06-12 15:44:45 +02:00
}
}
private fun cancelSendingIntent(tootId: Int): PendingIntent {
val intent = Intent(this, SendStatusService::class.java)
intent.putExtra(KEY_CANCEL, tootId)
return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
companion object {
private const val KEY_STATUS = "status"
private const val KEY_CANCEL = "cancel_id"
private const val CHANNEL_ID = "send_status"
private val MAX_RETRY_INTERVAL = TimeUnit.MINUTES.toMillis(1)
private var sendingNotificationId = -1 // use negative ids to not clash with other notis
private var errorNotificationId = Int.MIN_VALUE // use even more negative ids to not clash with other notis
2020-06-12 19:58:15 +02:00
fun sendStatusIntent(
context: Context,
statusToSend: StatusToSend
2020-06-12 15:44:45 +02:00
): Intent {
val intent = Intent(context, SendStatusService::class.java)
intent.putExtra(KEY_STATUS, statusToSend)
return intent
}
}
override val coroutineContext: CoroutineContext
get() = SupervisorJob() + Dispatchers.IO
}
@Parcelize
data class StatusToSend(
val accountId: Long,
val idempotencyKey: String = UUID.randomUUID().toString(),
val text: String,
2020-06-12 19:58:15 +02:00
val visibility: String,
val sensitive: Boolean,
val mediaUris: List<String>,
val mediaDescriptions: List<String> = emptyList(),
val inReplyToId: String? = null,
val savedTootUid: Int = 0,
var retries: Int = 0
2020-06-12 15:44:45 +02:00
) : Parcelable
2020-06-17 13:23:10 +02:00
private class UnrecoverableError : Exception()