chats: implement sending messages, prototype

main
Alibek Omarov 4 years ago
parent 69f37208a5
commit c2439405be
  1. 2
      app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt
  2. 32
      app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt
  3. 4
      app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
  4. 2
      app/src/main/java/com/keylesspalace/tusky/entity/Chat.kt
  5. 3
      app/src/main/java/com/keylesspalace/tusky/fragment/ChatsFragment.kt
  6. 4
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
  7. 360
      app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt
  8. 16
      app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt

@ -2,6 +2,7 @@ package com.keylesspalace.tusky.appstore
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.ChatMessage
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
@ -22,3 +23,4 @@ data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable
data class DomainMuteEvent(val instance: String): Dispatchable
data class ChatMessageDeliveredEvent(val chatMsg: ChatMessage) : Dispatchable

@ -12,7 +12,6 @@ import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.adapter.ChatMessagesAdapter
import com.keylesspalace.tusky.adapter.ChatMessagesViewHolder
import com.keylesspalace.tusky.adapter.TimelineAdapter
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Chat
import com.keylesspalace.tusky.entity.Emoji
@ -27,8 +26,11 @@ import androidx.arch.core.util.Function
import androidx.lifecycle.Lifecycle
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.*
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.repository.Placeholder
import com.keylesspalace.tusky.repository.TimelineRequestMode
import com.keylesspalace.tusky.service.MessageToSend
import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.util.*
import com.uber.autodispose.android.lifecycle.autoDispose
import io.reactivex.Observable
@ -51,6 +53,8 @@ class ChatActivity: BottomSheetActivity(),
lateinit var api: MastodonApi
@Inject
lateinit var chatsRepo: ChatRepository
@Inject
lateinit var serviceClient: ServiceClient
lateinit var adapter: ChatMessagesAdapter
@ -178,6 +182,32 @@ class ChatActivity: BottomSheetActivity(),
// recycler.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
recycler.adapter = adapter
sendButton.setOnClickListener {
serviceClient.sendChatMessage( MessageToSend(
editText.text.toString(),
null,
null,
accountManager.activeAccount!!.id,
this.chatId,
0
))
}
if (!eventRegistered) {
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event: Event? ->
when(event) {
is ChatMessageDeliveredEvent -> {
onRefresh()
editText.text.clear()
}
}
}
eventRegistered = true
}
tryCache()
}

@ -61,8 +61,8 @@ class NetworkModule {
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
//level = HttpLoggingInterceptor.Level.BASIC
level = HttpLoggingInterceptor.Level.HEADERS
//level = HttpLoggingInterceptor.Level.BODY
//level = HttpLoggingInterceptor.Level.HEADERS
level = HttpLoggingInterceptor.Level.BODY
})
}
}

@ -40,5 +40,5 @@ data class Chat(
data class NewChatMessage(
val content: String,
val media_id: String
val media_id: String?
)

@ -336,7 +336,8 @@ class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, Reselecta
}
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
this@ChatsFragment.onLoadMore()
if(!BROKEN_PAGINATION_IN_BACKEND)
this@ChatsFragment.onLoadMore()
}
}
recyclerView.addOnScrollListener(scrollListener)

@ -656,9 +656,11 @@ interface MastodonApi {
@POST("api/v1/pleroma/chats/{id}/messages")
fun createChatMessage(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
@Path("id") chatId: String,
@Body chatMessage: NewChatMessage
): Single<ChatMessage>
): Call<ChatMessage>
@FormUrlEncoded
@POST("api/v1/pleroma/chats/{id}/read")

@ -11,7 +11,6 @@ import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.Parcelable
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
@ -22,10 +21,9 @@ import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.NewStatus
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.SaveTootHelper
import dagger.android.AndroidInjection
import kotlinx.android.parcel.Parcelize
@ -51,8 +49,8 @@ class SendTootService : Service(), Injectable {
@Inject
lateinit var saveTootHelper: SaveTootHelper
private val tootsToSend = ConcurrentHashMap<Int, TootToSend>()
private val sendCalls = ConcurrentHashMap<Int, Call<Status>>()
private val tootsToSend = ConcurrentHashMap<Int, PostToSend>()
private val sendCalls = ConcurrentHashMap<Int, Either<Call<Status>, Call<ChatMessage>>>()
private val timer = Timer()
@ -68,59 +66,49 @@ class SendTootService : Service(), Injectable {
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.hasExtra(KEY_CANCEL)) {
cancelSending(intent.getIntExtra(KEY_CANCEL, 0))
return START_NOT_STICKY
}
if (intent.hasExtra(KEY_TOOT)) {
val tootToSend = intent.getParcelableExtra<TootToSend>(KEY_TOOT)
?: throw IllegalStateException("SendTootService started without $KEY_TOOT extra")
if (NotificationHelper.NOTIFICATION_USE_CHANNELS) {
val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_toot_notification_channel_name), NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(channel)
}
var notificationText = tootToSend.warningText
if (notificationText.isBlank()) {
notificationText = tootToSend.text
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_title))
.setContentText(notificationText)
.setProgress(1, 0, true)
.setOngoing(true)
.setColor(ContextCompat.getColor(this, R.color.tusky_blue))
.addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId))
if (tootsToSend.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())
}
val postToSend : PostToSend = (intent.getParcelableExtra<TootToSend>(KEY_TOOT)
?: intent.getParcelableExtra<MessageToSend>(KEY_CHATMSG)) as PostToSend?
?: throw IllegalStateException("SendTootService started without $KEY_CHATMSG or $KEY_TOOT extra")
tootsToSend[sendingNotificationId] = tootToSend
sendToot(sendingNotificationId--)
if (NotificationHelper.NOTIFICATION_USE_CHANNELS) {
val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_toot_notification_channel_name), NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(channel)
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_title))
.setContentText(postToSend.getNotificationText())
.setProgress(1, 0, true)
.setOngoing(true)
.setColor(ContextCompat.getColor(this, R.color.tusky_blue))
.addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId))
if (tootsToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
startForeground(sendingNotificationId, builder.build())
} else {
if (intent.hasExtra(KEY_CANCEL)) {
cancelSending(intent.getIntExtra(KEY_CANCEL, 0))
}
notificationManager.notify(sendingNotificationId, builder.build())
}
return START_NOT_STICKY
tootsToSend[sendingNotificationId] = postToSend
sendToot(sendingNotificationId--)
return START_NOT_STICKY
}
private fun sendToot(tootId: Int) {
// when tootToSend == null, sending has been canceled
val tootToSend = tootsToSend[tootId] ?: return
val postToSend = tootsToSend[tootId] ?: return
// when account == null, user has logged out, cancel sending
val account = accountManager.getAccountById(tootToSend.accountId)
val account = accountManager.getAccountById(postToSend.getAccountId())
if (account == null) {
tootsToSend.remove(tootId)
@ -129,87 +117,135 @@ class SendTootService : Service(), Injectable {
return
}
tootToSend.retries++
val contentType : String? = if(tootToSend.formattingSyntax.isNotEmpty()) tootToSend.formattingSyntax else null
val preview : Boolean? = if(tootToSend.preview) true else null
val newStatus = NewStatus(
tootToSend.text,
tootToSend.warningText,
tootToSend.inReplyToId,
tootToSend.visibility,
tootToSend.sensitive,
tootToSend.mediaIds,
tootToSend.scheduledAt,
tootToSend.poll,
contentType,
preview
)
val sendCall = mastodonApi.createStatus(
"Bearer " + account.accessToken,
account.domain,
tootToSend.idempotencyKey,
newStatus
)
sendCalls[tootId] = sendCall
val callback = object : Callback<Status> {
override fun onResponse(call: Call<Status>, response: Response<Status>) {
val scheduled = !tootToSend.scheduledAt.isNullOrEmpty()
tootsToSend.remove(tootId)
if (response.isSuccessful) {
// If the status was loaded from a draft, delete the draft and associated media files.
if (tootToSend.savedTootUid != 0) {
saveTootHelper.deleteDraft(tootToSend.savedTootUid)
}
postToSend.incrementRetries()
if(postToSend is TootToSend) {
val contentType : String? = if(postToSend.formattingSyntax.isNotEmpty()) postToSend.formattingSyntax else null
val preview : Boolean? = if(postToSend.preview) true else null
val newStatus = NewStatus(
postToSend.text,
postToSend.warningText,
postToSend.inReplyToId,
postToSend.visibility,
postToSend.sensitive,
postToSend.mediaIds,
postToSend.scheduledAt,
postToSend.poll,
contentType,
preview
)
val sendCall = mastodonApi.createStatus(
"Bearer " + account.accessToken,
account.domain,
postToSend.idempotencyKey,
newStatus
)
val callback = object : Callback<Status> {
override fun onResponse(call: Call<Status>, response: Response<Status>) {
val scheduled = !postToSend.scheduledAt.isNullOrEmpty()
tootsToSend.remove(tootId)
if (response.isSuccessful) {
// If the status was loaded from a draft, delete the draft and associated media files.
if (postToSend.savedTootUid != 0) {
saveTootHelper.deleteDraft(postToSend.savedTootUid)
}
when {
tootToSend.preview -> response.body()?.let(::StatusPreviewEvent)?.let(eventHub::dispatch)
scheduled -> response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch)
else -> response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch)
}
notificationManager.cancel(tootId)
when {
postToSend.preview -> response.body()?.let(::StatusPreviewEvent)?.let(eventHub::dispatch)
scheduled -> response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch)
else -> response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch)
}
notificationManager.cancel(tootId)
} else {
// the server refused to accept the toot, save toot & show error message
saveTootToDrafts(tootToSend)
} else {
// the server refused to accept the toot, save toot & show error message
saveTootToDrafts(postToSend)
val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_error_title))
.setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue))
val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_error_title))
.setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue))
notificationManager.cancel(tootId)
notificationManager.notify(errorNotificationId--, builder.build())
notificationManager.cancel(tootId)
notificationManager.notify(errorNotificationId--, builder.build())
}
stopSelfWhenDone()
}
stopSelfWhenDone()
override fun onFailure(call: Call<Status>, t: Throwable) {
var backoff = TimeUnit.SECONDS.toMillis(postToSend.retries.toLong())
if (backoff > MAX_RETRY_INTERVAL) {
backoff = MAX_RETRY_INTERVAL
}
timer.schedule(object : TimerTask() {
override fun run() {
sendToot(tootId)
}
}, backoff)
}
}
override fun onFailure(call: Call<Status>, t: Throwable) {
var backoff = TimeUnit.SECONDS.toMillis(tootToSend.retries.toLong())
if (backoff > MAX_RETRY_INTERVAL) {
backoff = MAX_RETRY_INTERVAL
sendCalls[tootId] = Either.Left(sendCall)
sendCall.enqueue(callback)
} else if(postToSend is MessageToSend) {
val newMessage = NewChatMessage(postToSend.text, postToSend.mediaId)
val sendCall = mastodonApi.createChatMessage(
"Bearer " + account.accessToken,
account.domain,
postToSend.chatId,
newMessage
)
val callback = object : Callback<ChatMessage> {
override fun onResponse(call: Call<ChatMessage>, response: Response<ChatMessage>) {
tootsToSend.remove(tootId)
if (response.isSuccessful) {
notificationManager.cancel(tootId)
eventHub.dispatch(ChatMessageDeliveredEvent(response.body()!!))
} else {
val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_error_title))
.setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue))
notificationManager.cancel(tootId)
notificationManager.notify(errorNotificationId--, builder.build())
}
stopSelfWhenDone()
}
timer.schedule(object : TimerTask() {
override fun run() {
sendToot(tootId)
override fun onFailure(call: Call<ChatMessage>, t: Throwable) {
var backoff = TimeUnit.SECONDS.toMillis(postToSend.retries.toLong())
if (backoff > MAX_RETRY_INTERVAL) {
backoff = MAX_RETRY_INTERVAL
}
}, backoff)
}
}
sendCall.enqueue(callback)
timer.schedule(object : TimerTask() {
override fun run() {
sendToot(tootId)
}
}, backoff)
}
}
sendCalls[tootId] = Either.Right(sendCall)
sendCall.enqueue(callback)
}
}
private fun stopSelfWhenDone() {
@ -224,9 +260,18 @@ class SendTootService : Service(), Injectable {
val tootToCancel = tootsToSend.remove(tootId)
if (tootToCancel != null) {
val sendCall = sendCalls.remove(tootId)
sendCall?.cancel()
saveTootToDrafts(tootToCancel)
sendCall?.let {
if(it.isLeft()) {
val sendStatusCall = it.asLeft()
sendStatusCall.cancel()
saveTootToDrafts(tootToCancel as TootToSend)
} else {
val sendMessageCall = it.asRight()
sendMessageCall.cancel()
}
}
val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
@ -274,6 +319,7 @@ class SendTootService : Service(), Injectable {
companion object {
private const val KEY_CHATMSG = "chatmsg"
private const val KEY_TOOT = "toot"
private const val KEY_CANCEL = "cancel_id"
private const val CHANNEL_ID = "send_toots"
@ -283,29 +329,35 @@ class SendTootService : Service(), Injectable {
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
private fun Intent.forwardUriPermissions(mediaUris: List<String>) {
if(mediaUris.isEmpty())
return
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uriClip = ClipData(
ClipDescription("Toot Media", arrayOf("image/*", "video/*")),
ClipData.Item(mediaUris[0])
)
mediaUris.drop(1).forEach { uriClip.addItem(ClipData.Item(it)) }
clipData = uriClip
}
@JvmStatic
fun sendTootIntent(context: Context,
tootToSend: TootToSend
): Intent {
fun sendMessageIntent(context: Context, msgToSend: MessageToSend): Intent {
val intent = Intent(context, SendTootService::class.java)
intent.putExtra(KEY_TOOT, tootToSend)
intent.putExtra(KEY_CHATMSG, msgToSend)
if(msgToSend.mediaUri != null)
intent.forwardUriPermissions(listOf(msgToSend.mediaUri))
if (tootToSend.mediaUris.isNotEmpty()) {
// forward uri permissions
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uriClip = ClipData(
ClipDescription("Toot Media", arrayOf("image/*", "video/*")),
ClipData.Item(tootToSend.mediaUris[0])
)
tootToSend.mediaUris
.drop(1)
.forEach { mediaUri ->
uriClip.addItem(ClipData.Item(mediaUri))
}
intent.clipData = uriClip
return intent
}
}
@JvmStatic
fun sendTootIntent(context: Context, tootToSend: TootToSend): Intent {
val intent = Intent(context, SendTootService::class.java)
intent.putExtra(KEY_TOOT, tootToSend)
intent.forwardUriPermissions(tootToSend.mediaUris)
return intent
}
@ -313,6 +365,34 @@ class SendTootService : Service(), Injectable {
}
}
interface PostToSend {
fun getAccountId() : Long
fun getNotificationText() : String
fun incrementRetries()
}
@Parcelize
data class MessageToSend(
val text: String,
val mediaId: String?,
val mediaUri: String?,
private val accountId: Long,
val chatId: String,
var retries: Int
) : Parcelable, PostToSend {
override fun getAccountId(): Long {
return accountId
}
override fun getNotificationText() : String {
return text
}
override fun incrementRetries() {
retries++
}
}
@Parcelize
data class TootToSend(
val text: String,
@ -330,8 +410,20 @@ data class TootToSend(
val savedJsonUrls: List<String>?,
val formattingSyntax: String,
val preview: Boolean,
val accountId: Long,
private val accountId: Long,
val savedTootUid: Int,
val idempotencyKey: String,
var retries: Int
) : Parcelable
) : Parcelable, PostToSend {
override fun getNotificationText() : String {
return if(warningText.isBlank()) text else warningText
}
override fun getAccountId() : Long {
return accountId
}
override fun incrementRetries() {
retries++
}
}

@ -16,19 +16,31 @@
package com.keylesspalace.tusky.service
import android.content.Context
import android.content.Intent
import android.os.Build
interface ServiceClient {
fun sendToot(tootToSend: TootToSend)
fun sendChatMessage(msgToSend: MessageToSend)
}
class ServiceClientImpl(private val context: Context) : ServiceClient {
override fun sendToot(tootToSend: TootToSend) {
val intent = SendTootService.sendTootIntent(context, tootToSend)
private fun startService(intent: Intent) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
override fun sendToot(tootToSend: TootToSend) {
val intent = SendTootService.sendTootIntent(context, tootToSend)
startService(intent)
}
override fun sendChatMessage(msgToSend: MessageToSend) {
val intent = SendTootService.sendMessageIntent(context, msgToSend)
startService(intent)
}
}
Loading…
Cancel
Save