migrate drafts to paging 3 (#2206)

* migrate drafts to paging 3

* migrate DraftHelper to coroutines
This commit is contained in:
Konrad Pozniak 2021-06-24 21:23:29 +02:00 committed by GitHub
parent 063dc49d41
commit f6dd131b95
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 357 additions and 337 deletions

View File

@ -35,6 +35,7 @@ import androidx.core.content.pm.ShortcutManagerCompat
import androidx.emoji.text.EmojiCompat
import androidx.emoji.text.EmojiCompat.InitCallback
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.MarginPageTransformer
import autodispose2.androidx.lifecycle.autoDispose
@ -61,7 +62,6 @@ import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ReselectableFragment
@ -84,6 +84,7 @@ import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch
import javax.inject.Inject
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector {
@ -614,29 +615,35 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
.setTitle(R.string.action_logout)
.setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName))
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this)
lifecycleScope.launch {
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity)
cacheUpdater.clearForUser(activeAccount.id)
conversationRepository.deleteCacheForAccount(activeAccount.id)
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
removeShortcut(this, activeAccount)
removeShortcut(this@MainActivity, activeAccount)
val newAccount = accountManager.logActiveAccountOut()
if (!NotificationHelper.areNotificationsEnabled(this, accountManager)) {
NotificationHelper.disablePullNotifications(this)
if (!NotificationHelper.areNotificationsEnabled(
this@MainActivity,
accountManager
)
) {
NotificationHelper.disablePullNotifications(this@MainActivity)
}
val intent = if (newAccount == null) {
LoginActivity.getIntent(this, false)
LoginActivity.getIntent(this@MainActivity, false)
} else {
Intent(this, MainActivity::class.java)
Intent(this@MainActivity, MainActivity::class.java)
}
startActivity(intent)
finishWithoutSlideOutAnimation()
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
}
private fun fetchUserInfo() {
private fun fetchUserInfo() {
mastodonApi.accountVerifyCredentials()
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
@ -648,9 +655,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
Log.e(TAG, "Failed to fetch user info. " + throwable.message)
}
)
}
}
private fun onFetchUserInfoSuccess(me: Account) {
private fun onFetchUserInfoSuccess(me: Account) {
glide.asBitmap()
.load(me.header)
.into(header.accountHeaderBackground)
@ -664,9 +671,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
updateProfiles()
updateShortcut(this, accountManager.activeAccount!!)
}
}
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
glide.asDrawable()
@ -697,9 +704,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
}
})
}
}
private fun fetchAnnouncements() {
private fun fetchAnnouncements() {
mastodonApi.listAnnouncements(false)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
@ -712,13 +719,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
Log.w(TAG, "Failed to fetch announcements.", it)
}
)
}
}
private fun updateAnnouncementsBadge() {
private fun updateAnnouncementsBadge() {
binding.mainDrawer.updateBadge(DRAWER_ITEM_ANNOUNCEMENTS, StringHolder(if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString()))
}
}
private fun updateProfiles() {
private fun updateProfiles() {
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val profiles: MutableList<IProfile> = accountManager.getAllAccountsOrderedByActive().map { acc ->
val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header, animateEmojis))
@ -743,18 +750,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
header.clear()
header.profiles = profiles
header.setActiveProfile(accountManager.activeAccount!!.id)
}
}
override fun getActionButton() = binding.composeButton
override fun getActionButton() = binding.composeButton
override fun androidInjector() = androidInjector
override fun androidInjector() = androidInjector
companion object {
companion object {
private const val TAG = "MainActivity" // logging tag
private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13
private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14
const val STATUS_URL = "statusUrl"
}
}
}
private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem {

View File

@ -21,6 +21,7 @@ import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.search.SearchType
@ -36,6 +37,7 @@ import com.keylesspalace.tusky.util.*
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.launch
import java.util.*
import javax.inject.Inject
@ -214,14 +216,15 @@ class ComposeViewModel @Inject constructor(
}
fun deleteDraft() {
viewModelScope.launch {
if (draftId != 0) {
draftHelper.deleteDraftAndAttachments(draftId)
.subscribe()
}
}
}
fun saveDraft(content: String, contentWarning: String) {
viewModelScope.launch {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
media.value?.forEach { item ->
@ -241,7 +244,8 @@ class ComposeViewModel @Inject constructor(
mediaDescriptions = mediaDescriptions,
poll = poll.value,
failedToSend = false
).subscribe()
)
}
}
/**

View File

@ -28,13 +28,12 @@ import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.IOUtils
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import java.util.Date
import java.util.Locale
import javax.inject.Inject
class DraftHelper @Inject constructor(
@ -44,7 +43,7 @@ class DraftHelper @Inject constructor(
private val draftDao = db.draftDao()
fun saveDraft(
suspend fun saveDraft(
draftId: Int,
accountId: Long,
inReplyToId: String?,
@ -56,9 +55,7 @@ class DraftHelper @Inject constructor(
mediaDescriptions: List<String?>,
poll: NewPoll?,
failedToSend: Boolean
): Completable {
return Single.fromCallable {
) = withContext(Dispatchers.IO) {
val externalFilesDir = context.getExternalFilesDir("Tusky")
if (externalFilesDir == null || !(externalFilesDir.exists())) {
@ -103,7 +100,7 @@ class DraftHelper @Inject constructor(
)
}
DraftEntity(
val draft = DraftEntity(
id = draftId,
accountId = accountId,
inReplyToId = inReplyToId,
@ -116,42 +113,34 @@ class DraftHelper @Inject constructor(
failedToSend = failedToSend
)
}.flatMapCompletable { draft ->
draftDao.insertOrReplace(draft)
}.subscribeOn(Schedulers.io())
}
fun deleteDraftAndAttachments(draftId: Int): Completable {
return draftDao.find(draftId)
.flatMapCompletable { draft ->
draft?.let {
deleteDraftAndAttachments(it)
}
}
}
fun deleteDraftAndAttachments(draft: DraftEntity): Completable {
return deleteAttachments(draft)
.andThen(draftDao.delete(draft.id))
}
fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) {
draftDao.loadDraftsSingle(accountId)
.flatMapObservable { Observable.fromIterable(it) }
.flatMapCompletable { draft ->
suspend fun deleteDraftAndAttachments(draftId: Int) {
draftDao.find(draftId)?.let { draft ->
deleteDraftAndAttachments(draft)
}.subscribeOn(Schedulers.io())
.subscribe()
}
}
fun deleteAttachments(draft: DraftEntity): Completable {
return Completable.fromCallable {
suspend fun deleteDraftAndAttachments(draft: DraftEntity) {
deleteAttachments(draft)
draftDao.delete(draft.id)
}
suspend fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) {
draftDao.loadDrafts(accountId).forEach { draft ->
deleteDraftAndAttachments(draft)
}
}
suspend fun deleteAttachments(draft: DraftEntity) {
withContext(Dispatchers.IO) {
draft.attachments.forEach { attachment ->
if (context.contentResolver.delete(attachment.uri, null, null) == 0) {
Log.e("DraftHelper", "Did not delete file ${attachment.uriString}")
}
}
}.subscribeOn(Schedulers.io())
}
}
private fun Uri.isNotInFolder(folder: File): Boolean {

View File

@ -22,6 +22,7 @@ import android.util.Log
import android.widget.LinearLayout
import android.widget.Toast
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
@ -34,9 +35,10 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.visible
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import retrofit2.HttpException
import javax.inject.Inject
@ -51,7 +53,6 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
private lateinit var bottomSheet: BottomSheetBehavior<LinearLayout>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDraftsBinding.inflate(layoutInflater)
@ -74,16 +75,15 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root)
viewModel.drafts.observe(this) { draftList ->
if (draftList.isEmpty()) {
binding.draftsRecyclerView.hide()
binding.draftsErrorMessageView.show()
} else {
binding.draftsRecyclerView.show()
binding.draftsErrorMessageView.hide()
adapter.submitList(draftList)
lifecycleScope.launch {
viewModel.drafts.collectLatest { draftData ->
adapter.submitData(draftData)
}
}
adapter.addLoadStateListener {
binding.draftsErrorMessageView.visible(adapter.itemCount == 0)
}
}
override fun onOpenDraft(draft: DraftEntity) {

View File

@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.drafts
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@ -35,7 +35,7 @@ interface DraftActionListener {
class DraftsAdapter(
private val listener: DraftActionListener
) : PagedListAdapter<DraftEntity, BindingHolder<ItemDraftBinding>>(
) : PagingDataAdapter<DraftEntity, BindingHolder<ItemDraftBinding>>(
object : DiffUtil.ItemCallback<DraftEntity>() {
override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean {
return oldItem.id == newItem.id
@ -87,6 +87,5 @@ class DraftsAdapter(
holder.binding.draftPoll.hide()
}
}
}
}

View File

@ -16,13 +16,17 @@
package com.keylesspalace.tusky.components.drafts
import androidx.lifecycle.ViewModel
import androidx.paging.toLiveData
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.launch
import javax.inject.Inject
class DraftsViewModel @Inject constructor(
@ -32,32 +36,39 @@ class DraftsViewModel @Inject constructor(
val draftHelper: DraftHelper
) : ViewModel() {
val drafts = database.draftDao().loadDrafts(accountManager.activeAccount?.id!!).toLiveData(pageSize = 20)
val drafts = Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { database.draftDao().draftsPagingSource(accountManager.activeAccount?.id!!) }
).flow
.cachedIn(viewModelScope)
private val deletedDrafts: MutableList<DraftEntity> = mutableListOf()
fun deleteDraft(draft: DraftEntity) {
// this does not immediately delete media files to avoid unnecessary file operations
// in case the user decides to restore the draft
viewModelScope.launch {
database.draftDao().delete(draft.id)
.subscribe()
deletedDrafts.add(draft)
}
}
fun restoreDraft(draft: DraftEntity) {
viewModelScope.launch {
database.draftDao().insertOrReplace(draft)
.subscribe()
deletedDrafts.remove(draft)
}
}
fun getToot(tootId: String): Single<Status> {
return api.status(tootId)
}
override fun onCleared() {
viewModelScope.launch {
deletedDrafts.forEach {
draftHelper.deleteAttachments(it).subscribe()
draftHelper.deleteAttachments(it)
}
}
}
}

View File

@ -15,30 +15,28 @@
package com.keylesspalace.tusky.db
import androidx.paging.DataSource
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
@Dao
interface DraftDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(draft: DraftEntity): Completable
suspend fun insertOrReplace(draft: DraftEntity)
@Query("SELECT * FROM DraftEntity WHERE accountId = :accountId ORDER BY id ASC")
fun loadDrafts(accountId: Long): DataSource.Factory<Int, DraftEntity>
fun draftsPagingSource(accountId: Long): PagingSource<Int, DraftEntity>
@Query("SELECT * FROM DraftEntity WHERE accountId = :accountId")
fun loadDraftsSingle(accountId: Long): Single<List<DraftEntity>>
suspend fun loadDrafts(accountId: Long): List<DraftEntity>
@Query("DELETE FROM DraftEntity WHERE id = :id")
fun delete(id: Int): Completable
suspend fun delete(id: Int)
@Query("SELECT * FROM DraftEntity WHERE id = :id")
fun find(id: Int): Single<DraftEntity?>
suspend fun find(id: Int): DraftEntity?
}

View File

@ -27,6 +27,10 @@ import com.keylesspalace.tusky.entity.NewStatus
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import dagger.android.AndroidInjection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import retrofit2.Call
import retrofit2.Callback
@ -49,6 +53,9 @@ class SendTootService : Service(), Injectable {
@Inject
lateinit var draftHelper: DraftHelper
private val supervisorJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob)
private val tootsToSend = ConcurrentHashMap<Int, TootToSend>()
private val sendCalls = ConcurrentHashMap<Int, Call<Status>>()
@ -148,7 +155,6 @@ class SendTootService : Service(), Injectable {
newStatus
)
sendCalls[tootId] = sendCall
val callback = object : Callback<Status> {
@ -160,8 +166,9 @@ class SendTootService : Service(), Injectable {
if (response.isSuccessful) {
// If the status was loaded from a draft, delete the draft and associated media files.
if (tootToSend.draftId != 0) {
serviceScope.launch {
draftHelper.deleteDraftAndAttachments(tootToSend.draftId)
.subscribe()
}
}
if (scheduled) {
@ -244,7 +251,7 @@ class SendTootService : Service(), Injectable {
}
private fun saveTootToDrafts(toot: TootToSend) {
serviceScope.launch {
draftHelper.saveDraft(
draftId = toot.draftId,
accountId = toot.accountId,
@ -257,7 +264,8 @@ class SendTootService : Service(), Injectable {
mediaDescriptions = toot.mediaDescriptions,
poll = toot.poll,
failedToSend = true
).subscribe()
)
}
}
private fun cancelSendingIntent(tootId: Int): PendingIntent {
@ -269,6 +277,10 @@ class SendTootService : Service(), Injectable {
return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
override fun onDestroy() {
super.onDestroy()
supervisorJob.cancel()
}
companion object {