diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index 61d1cd13..6f2682ed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.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) : 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 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt index 3613f320..8a77e792 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt @@ -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() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index 7cf46ed6..6d81b2ff 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -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 }) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Chat.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Chat.kt index cedda228..35c6ce12 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Chat.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Chat.kt @@ -40,5 +40,5 @@ data class Chat( data class NewChatMessage( val content: String, - val media_id: String + val media_id: String? ) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ChatsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ChatsFragment.kt index 08903fc0..db1e7660 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ChatsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ChatsFragment.kt @@ -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) diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index abe0f89e..907a2bb7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -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 + ): Call @FormUrlEncoded @POST("api/v1/pleroma/chats/{id}/read") diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt index 08f7aa4b..44587454 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt @@ -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() - private val sendCalls = ConcurrentHashMap>() + private val tootsToSend = ConcurrentHashMap() + private val sendCalls = ConcurrentHashMap, Call>>() 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(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(KEY_TOOT) + ?: intent.getParcelableExtra(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 { - override fun onResponse(call: Call, response: Response) { - - 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 { + override fun onResponse(call: Call, response: Response) { + + 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, 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, 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 { + override fun onResponse(call: Call, response: Response) { + 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, 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) { + 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?, 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++ + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt b/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt index b60377f5..8fff6e42 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt @@ -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) + } } \ No newline at end of file