/* * 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 . */ 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 import at.connyduck.pixelcat.components.util.getMimeType import at.connyduck.pixelcat.db.AccountManager import at.connyduck.pixelcat.model.NewStatus import at.connyduck.pixelcat.network.FediverseApi import at.connyduck.pixelcat.network.calladapter.NetworkResponseError import dagger.android.DaggerService 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 import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody import java.io.File import java.util.Timer import java.util.TimerTask import java.util.UUID 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() private val sendJobs = ConcurrentHashMap() 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(KEY_STATUS) ?: throw IllegalStateException("SendTootService started without $KEY_STATUS extra") 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) .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)) 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 { val mediaIds = statusToSend.mediaUris.map { mediaPath -> val mimeType = getMimeType(mediaPath) if (mimeType == null) { // unrecoverable error onError(UnrecoverableError(), id) return@launch } val fileExtension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) val filePart = File(mediaPath).asRequestBody(mimeType.toMediaType()) val body = MultipartBody.Part.createFormData("file", "test.$fileExtension", filePart) api.uploadMedia(body).fold( { attachment -> attachment.id }, { error -> onError(error, id) return@launch } ) } 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( { statusesToSend.remove(id) stopSelfWhenDone() }, { onError(it, id) } ) }.apply { sendJobs[id] = this } } private fun onError(error: Throwable, id: Int) { val statusToSend = statusesToSend[id] ?: return when (error) { is NetworkResponseError.ApiError, is UnrecoverableError -> { // 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()) } else -> { 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 ) } } } 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() // saveTootToDrafts(tootToCancel) val builder = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_cat) .setContentTitle(getString(R.string.send_status_notification_cancel_title)) // .setContentText(getString(R.string.send_toot_notification_saved_content)) .setColor(getColorForAttr(android.R.attr.colorPrimary)) notificationManager.notify(id, builder.build()) timer.schedule( object : TimerTask() { override fun run() { notificationManager.cancel(id) stopSelfWhenDone() } }, 5000 ) } } 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 fun sendStatusIntent( context: Context, statusToSend: StatusToSend ): 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, val visibility: String, val sensitive: Boolean, val mediaUris: List, val mediaDescriptions: List = emptyList(), val inReplyToId: String? = null, val savedTootUid: Int = 0, var retries: Int = 0 ) : Parcelable private class UnrecoverableError : Exception()