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
import androidx.emoji.text.EmojiCompat.InitCallback import androidx.emoji.text.EmojiCompat.InitCallback
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.MarginPageTransformer
import autodispose2.androidx.lifecycle.autoDispose 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.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment
@ -84,6 +84,7 @@ import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector { class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector {
@ -218,18 +219,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
NotificationHelper.disablePullNotifications(this) NotificationHelper.disablePullNotifications(this)
} }
eventHub.events eventHub.events
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY) .autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event: Event? -> .subscribe { event: Event? ->
when (event) { when (event) {
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
is MainTabsChangedEvent -> setupTabs(false) is MainTabsChangedEvent -> setupTabs(false)
is AnnouncementReadEvent -> { is AnnouncementReadEvent -> {
unreadAnnouncementsCount-- unreadAnnouncementsCount--
updateAnnouncementsBadge() updateAnnouncementsBadge()
}
} }
} }
}
Schedulers.io().scheduleDirect { Schedulers.io().scheduleDirect {
// Flush old media that was cached for sharing // Flush old media that was cached for sharing
@ -341,13 +342,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) { override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
if (animateAvatars) { if (animateAvatars) {
glide.load(uri) glide.load(uri)
.placeholder(placeholder) .placeholder(placeholder)
.into(imageView) .into(imageView)
} else { } else {
glide.asBitmap() glide.asBitmap()
.load(uri) .load(uri)
.placeholder(placeholder) .placeholder(placeholder)
.into(imageView) .into(imageView)
} }
} }
@ -367,114 +368,114 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
binding.mainDrawer.apply { binding.mainDrawer.apply {
tintStatusBar = true tintStatusBar = true
addItems( addItems(
primaryDrawerItem { primaryDrawerItem {
nameRes = R.string.action_edit_profile nameRes = R.string.action_edit_profile
iconicsIcon = GoogleMaterial.Icon.gmd_person iconicsIcon = GoogleMaterial.Icon.gmd_person
onClick = { onClick = {
val intent = Intent(context, EditProfileActivity::class.java) val intent = Intent(context, EditProfileActivity::class.java)
startActivityWithSlideInAnimation(intent) startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_favourites
isSelectable = false
iconicsIcon = GoogleMaterial.Icon.gmd_star
onClick = {
val intent = StatusListActivity.newFavouritesIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_bookmarks
iconicsIcon = GoogleMaterial.Icon.gmd_bookmark
onClick = {
val intent = StatusListActivity.newBookmarksIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_follow_requests
iconicsIcon = GoogleMaterial.Icon.gmd_person_add
onClick = {
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_lists
iconicsIcon = GoogleMaterial.Icon.gmd_list
onClick = {
startActivityWithSlideInAnimation(ListsActivity.newIntent(context))
}
},
primaryDrawerItem {
nameRes = R.string.action_access_drafts
iconRes = R.drawable.ic_notebook
onClick = {
val intent = DraftsActivity.newIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_access_scheduled_toot
iconRes = R.drawable.ic_access_time
onClick = {
startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context))
}
},
primaryDrawerItem {
identifier = DRAWER_ITEM_ANNOUNCEMENTS
nameRes = R.string.title_announcements
iconRes = R.drawable.ic_bullhorn_24dp
onClick = {
startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context))
}
badgeStyle = BadgeStyle().apply {
textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary))
color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary))
}
},
DividerDrawerItem(),
secondaryDrawerItem {
nameRes = R.string.action_view_account_preferences
iconRes = R.drawable.ic_account_settings
onClick = {
val intent = PreferencesActivity.newIntent(context, PreferencesActivity.ACCOUNT_PREFERENCES)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.action_view_preferences
iconicsIcon = GoogleMaterial.Icon.gmd_settings
onClick = {
val intent = PreferencesActivity.newIntent(context, PreferencesActivity.GENERAL_PREFERENCES)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.about_title_activity
iconicsIcon = GoogleMaterial.Icon.gmd_info
onClick = {
val intent = Intent(context, AboutActivity::class.java)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.action_logout
iconRes = R.drawable.ic_logout
onClick = ::logout
} }
},
primaryDrawerItem {
nameRes = R.string.action_view_favourites
isSelectable = false
iconicsIcon = GoogleMaterial.Icon.gmd_star
onClick = {
val intent = StatusListActivity.newFavouritesIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_bookmarks
iconicsIcon = GoogleMaterial.Icon.gmd_bookmark
onClick = {
val intent = StatusListActivity.newBookmarksIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_follow_requests
iconicsIcon = GoogleMaterial.Icon.gmd_person_add
onClick = {
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_lists
iconicsIcon = GoogleMaterial.Icon.gmd_list
onClick = {
startActivityWithSlideInAnimation(ListsActivity.newIntent(context))
}
},
primaryDrawerItem {
nameRes = R.string.action_access_drafts
iconRes = R.drawable.ic_notebook
onClick = {
val intent = DraftsActivity.newIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_access_scheduled_toot
iconRes = R.drawable.ic_access_time
onClick = {
startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context))
}
},
primaryDrawerItem {
identifier = DRAWER_ITEM_ANNOUNCEMENTS
nameRes = R.string.title_announcements
iconRes = R.drawable.ic_bullhorn_24dp
onClick = {
startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context))
}
badgeStyle = BadgeStyle().apply {
textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary))
color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary))
}
},
DividerDrawerItem(),
secondaryDrawerItem {
nameRes = R.string.action_view_account_preferences
iconRes = R.drawable.ic_account_settings
onClick = {
val intent = PreferencesActivity.newIntent(context, PreferencesActivity.ACCOUNT_PREFERENCES)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.action_view_preferences
iconicsIcon = GoogleMaterial.Icon.gmd_settings
onClick = {
val intent = PreferencesActivity.newIntent(context, PreferencesActivity.GENERAL_PREFERENCES)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.about_title_activity
iconicsIcon = GoogleMaterial.Icon.gmd_info
onClick = {
val intent = Intent(context, AboutActivity::class.java)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.action_logout
iconRes = R.drawable.ic_logout
onClick = ::logout
}
) )
if (addSearchButton) { if (addSearchButton) {
binding.mainDrawer.addItemsAtPosition(4, binding.mainDrawer.addItemsAtPosition(4,
primaryDrawerItem { primaryDrawerItem {
nameRes = R.string.action_search nameRes = R.string.action_search
iconicsIcon = GoogleMaterial.Icon.gmd_search iconicsIcon = GoogleMaterial.Icon.gmd_search
onClick = { onClick = {
startActivityWithSlideInAnimation(SearchActivity.getIntent(context)) startActivityWithSlideInAnimation(SearchActivity.getIntent(context))
} }
}) })
} }
setSavedInstance(savedInstanceState) setSavedInstance(savedInstanceState)
@ -482,11 +483,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
binding.mainDrawer.addItems( binding.mainDrawer.addItems(
secondaryDrawerItem { secondaryDrawerItem {
nameText = "debug" nameText = "debug"
isEnabled = false isEnabled = false
textColor = ColorStateList.valueOf(Color.GREEN) textColor = ColorStateList.valueOf(Color.GREEN)
} }
) )
} }
EmojiCompat.get().registerInitCallback(emojiInitCallback) EmojiCompat.get().registerInitCallback(emojiInitCallback)
@ -519,7 +520,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
activeTabLayout.removeAllTabs() activeTabLayout.removeAllTabs()
for (i in tabs.indices) { for (i in tabs.indices) {
val tab = activeTabLayout.newTab() val tab = activeTabLayout.newTab()
.setIcon(tabs[i].icon) .setIcon(tabs[i].icon)
if (tabs[i].id == LIST) { if (tabs[i].id == LIST) {
tab.contentDescription = tabs[i].arguments[1] tab.contentDescription = tabs[i].arguments[1]
} else { } else {
@ -611,168 +612,174 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun logout() { private fun logout() {
accountManager.activeAccount?.let { activeAccount -> accountManager.activeAccount?.let { activeAccount ->
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.action_logout) .setTitle(R.string.action_logout)
.setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName)) .setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName))
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this) lifecycleScope.launch {
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity)
cacheUpdater.clearForUser(activeAccount.id) cacheUpdater.clearForUser(activeAccount.id)
conversationRepository.deleteCacheForAccount(activeAccount.id) conversationRepository.deleteCacheForAccount(activeAccount.id)
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id) draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
removeShortcut(this, activeAccount) removeShortcut(this@MainActivity, activeAccount)
val newAccount = accountManager.logActiveAccountOut() val newAccount = accountManager.logActiveAccountOut()
if (!NotificationHelper.areNotificationsEnabled(this, accountManager)) { if (!NotificationHelper.areNotificationsEnabled(
NotificationHelper.disablePullNotifications(this) this@MainActivity,
accountManager
)
) {
NotificationHelper.disablePullNotifications(this@MainActivity)
} }
val intent = if (newAccount == null) { val intent = if (newAccount == null) {
LoginActivity.getIntent(this, false) LoginActivity.getIntent(this@MainActivity, false)
} else { } else {
Intent(this, MainActivity::class.java) Intent(this@MainActivity, MainActivity::class.java)
} }
startActivity(intent) startActivity(intent)
finishWithoutSlideOutAnimation() finishWithoutSlideOutAnimation()
} }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
private fun fetchUserInfo() {
mastodonApi.accountVerifyCredentials()
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(
{ userInfo ->
onFetchUserInfoSuccess(userInfo)
},
{ throwable ->
Log.e(TAG, "Failed to fetch user info. " + throwable.message)
}
)
}
private fun onFetchUserInfoSuccess(me: Account) {
glide.asBitmap()
.load(me.header)
.into(header.accountHeaderBackground)
loadDrawerAvatar(me.avatar, false)
accountManager.updateActiveAccount(me)
NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this)
accountLocked = me.locked
updateProfiles()
updateShortcut(this, accountManager.activeAccount!!)
}
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
glide.asDrawable()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
} }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
private fun fetchUserInfo() {
mastodonApi.accountVerifyCredentials()
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(
{ userInfo ->
onFetchUserInfoSuccess(userInfo)
},
{ throwable ->
Log.e(TAG, "Failed to fetch user info. " + throwable.message)
} }
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) { )
}
override fun onLoadStarted(placeholder: Drawable?) { private fun onFetchUserInfoSuccess(me: Account) {
if (placeholder != null) { glide.asBitmap()
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) .load(me.header)
} .into(header.accountHeaderBackground)
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) { loadDrawerAvatar(me.avatar, false)
binding.mainToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
}
override fun onLoadCleared(placeholder: Drawable?) { accountManager.updateActiveAccount(me)
if (placeholder != null) { NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this)
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
})
}
private fun fetchAnnouncements() { accountLocked = me.locked
mastodonApi.listAnnouncements(false)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(
{ announcements ->
unreadAnnouncementsCount = announcements.count { !it.read }
updateAnnouncementsBadge()
},
{
Log.w(TAG, "Failed to fetch announcements.", it)
}
)
}
private fun updateAnnouncementsBadge() { updateProfiles()
binding.mainDrawer.updateBadge(DRAWER_ITEM_ANNOUNCEMENTS, StringHolder(if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString())) updateShortcut(this, accountManager.activeAccount!!)
} }
private fun updateProfiles() { private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
val profiles: MutableList<IProfile> = accountManager.getAllAccountsOrderedByActive().map { acc ->
val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header, animateEmojis))
ProfileDrawerItem().apply { glide.asDrawable()
isSelected = acc.isActive .load(avatarUrl)
nameText = emojifiedName .transform(
iconUrl = acc.profilePictureUrl RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
isNameShown = true )
identifier = acc.id .apply {
descriptionText = acc.fullName if (showPlaceholder) {
} placeholder(R.drawable.avatar_default)
}.toMutableList()
// reuse the already existing "add account" item
for (profile in header.profiles.orEmpty()) {
if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) {
profiles.add(profile)
break
} }
} }
header.clear() .into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
header.profiles = profiles
header.setActiveProfile(accountManager.activeAccount!!.id) override fun onLoadStarted(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
}
override fun onLoadCleared(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
})
}
private fun fetchAnnouncements() {
mastodonApi.listAnnouncements(false)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(
{ announcements ->
unreadAnnouncementsCount = announcements.count { !it.read }
updateAnnouncementsBadge()
},
{
Log.w(TAG, "Failed to fetch announcements.", it)
}
)
}
private fun updateAnnouncementsBadge() {
binding.mainDrawer.updateBadge(DRAWER_ITEM_ANNOUNCEMENTS, StringHolder(if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString()))
}
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))
ProfileDrawerItem().apply {
isSelected = acc.isActive
nameText = emojifiedName
iconUrl = acc.profilePictureUrl
isNameShown = true
identifier = acc.id
descriptionText = acc.fullName
}
}.toMutableList()
// reuse the already existing "add account" item
for (profile in header.profiles.orEmpty()) {
if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) {
profiles.add(profile)
break
}
} }
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 TAG = "MainActivity" // logging tag
private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13 private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13
private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14 private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14
const val STATUS_URL = "statusUrl" const val STATUS_URL = "statusUrl"
} }
} }
private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem { private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem {
return PrimaryDrawerItem() return PrimaryDrawerItem()
.apply { .apply {
isSelectable = false isSelectable = false
isIconTinted = true isIconTinted = true
} }
.apply(block) .apply(block)
} }
private inline fun secondaryDrawerItem(block: SecondaryDrawerItem.() -> Unit): SecondaryDrawerItem { private inline fun secondaryDrawerItem(block: SecondaryDrawerItem.() -> Unit): SecondaryDrawerItem {
return SecondaryDrawerItem() return SecondaryDrawerItem()
.apply { .apply {
isSelectable = false isSelectable = false
isIconTinted = true isIconTinted = true
} }
.apply(block) .apply(block)
} }
private var AbstractDrawerItem<*, *>.onClick: () -> Unit private var AbstractDrawerItem<*, *>.onClick: () -> Unit

View file

@ -21,6 +21,7 @@ import androidx.core.net.toUri
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.search.SearchType 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.Observable
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.launch
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
@ -214,22 +216,23 @@ class ComposeViewModel @Inject constructor(
} }
fun deleteDraft() { fun deleteDraft() {
if (draftId != 0) { viewModelScope.launch {
draftHelper.deleteDraftAndAttachments(draftId) if (draftId != 0) {
.subscribe() draftHelper.deleteDraftAndAttachments(draftId)
}
} }
} }
fun saveDraft(content: String, contentWarning: String) { fun saveDraft(content: String, contentWarning: String) {
viewModelScope.launch {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
media.value?.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
}
val mediaUris: MutableList<String> = mutableListOf() draftHelper.saveDraft(
val mediaDescriptions: MutableList<String?> = mutableListOf()
media.value?.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
}
draftHelper.saveDraft(
draftId = draftId, draftId = draftId,
accountId = accountManager.activeAccount?.id!!, accountId = accountManager.activeAccount?.id!!,
inReplyToId = inReplyToId, inReplyToId = inReplyToId,
@ -241,7 +244,8 @@ class ComposeViewModel @Inject constructor(
mediaDescriptions = mediaDescriptions, mediaDescriptions = mediaDescriptions,
poll = poll.value, poll = poll.value,
failedToSend = false 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.NewPoll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.IOUtils import com.keylesspalace.tusky.util.IOUtils
import io.reactivex.rxjava3.core.Completable import kotlinx.coroutines.Dispatchers
import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.withContext
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
class DraftHelper @Inject constructor( class DraftHelper @Inject constructor(
@ -44,7 +43,7 @@ class DraftHelper @Inject constructor(
private val draftDao = db.draftDao() private val draftDao = db.draftDao()
fun saveDraft( suspend fun saveDraft(
draftId: Int, draftId: Int,
accountId: Long, accountId: Long,
inReplyToId: String?, inReplyToId: String?,
@ -56,9 +55,7 @@ class DraftHelper @Inject constructor(
mediaDescriptions: List<String?>, mediaDescriptions: List<String?>,
poll: NewPoll?, poll: NewPoll?,
failedToSend: Boolean failedToSend: Boolean
): Completable { ) = withContext(Dispatchers.IO) {
return Single.fromCallable {
val externalFilesDir = context.getExternalFilesDir("Tusky") val externalFilesDir = context.getExternalFilesDir("Tusky")
if (externalFilesDir == null || !(externalFilesDir.exists())) { if (externalFilesDir == null || !(externalFilesDir.exists())) {
@ -103,7 +100,7 @@ class DraftHelper @Inject constructor(
) )
} }
DraftEntity( val draft = DraftEntity(
id = draftId, id = draftId,
accountId = accountId, accountId = accountId,
inReplyToId = inReplyToId, inReplyToId = inReplyToId,
@ -116,42 +113,34 @@ class DraftHelper @Inject constructor(
failedToSend = failedToSend failedToSend = failedToSend
) )
}.flatMapCompletable { draft ->
draftDao.insertOrReplace(draft) draftDao.insertOrReplace(draft)
}.subscribeOn(Schedulers.io())
} }
fun deleteDraftAndAttachments(draftId: Int): Completable { suspend fun deleteDraftAndAttachments(draftId: Int) {
return draftDao.find(draftId) draftDao.find(draftId)?.let { draft ->
.flatMapCompletable { draft -> deleteDraftAndAttachments(draft)
draft?.let { }
deleteDraftAndAttachments(it)
}
}
} }
fun deleteDraftAndAttachments(draft: DraftEntity): Completable { suspend fun deleteDraftAndAttachments(draft: DraftEntity) {
return deleteAttachments(draft) deleteAttachments(draft)
.andThen(draftDao.delete(draft.id)) draftDao.delete(draft.id)
} }
fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) { suspend fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) {
draftDao.loadDraftsSingle(accountId) draftDao.loadDrafts(accountId).forEach { draft ->
.flatMapObservable { Observable.fromIterable(it) } deleteDraftAndAttachments(draft)
.flatMapCompletable { draft -> }
deleteDraftAndAttachments(draft)
}.subscribeOn(Schedulers.io())
.subscribe()
} }
fun deleteAttachments(draft: DraftEntity): Completable { suspend fun deleteAttachments(draft: DraftEntity) {
return Completable.fromCallable { withContext(Dispatchers.IO) {
draft.attachments.forEach { attachment -> draft.attachments.forEach { attachment ->
if (context.contentResolver.delete(attachment.uri, null, null) == 0) { if (context.contentResolver.delete(attachment.uri, null, null) == 0) {
Log.e("DraftHelper", "Did not delete file ${attachment.uriString}") Log.e("DraftHelper", "Did not delete file ${attachment.uriString}")
} }
} }
}.subscribeOn(Schedulers.io()) }
} }
private fun Uri.isNotInFolder(folder: File): Boolean { private fun Uri.isNotInFolder(folder: File): Boolean {

View file

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

View file

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

View file

@ -16,13 +16,17 @@
package com.keylesspalace.tusky.components.drafts package com.keylesspalace.tusky.components.drafts
import androidx.lifecycle.ViewModel 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.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class DraftsViewModel @Inject constructor( class DraftsViewModel @Inject constructor(
@ -32,22 +36,28 @@ class DraftsViewModel @Inject constructor(
val draftHelper: DraftHelper val draftHelper: DraftHelper
) : ViewModel() { ) : 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() private val deletedDrafts: MutableList<DraftEntity> = mutableListOf()
fun deleteDraft(draft: DraftEntity) { fun deleteDraft(draft: DraftEntity) {
// this does not immediately delete media files to avoid unnecessary file operations // this does not immediately delete media files to avoid unnecessary file operations
// in case the user decides to restore the draft // in case the user decides to restore the draft
database.draftDao().delete(draft.id) viewModelScope.launch {
.subscribe() database.draftDao().delete(draft.id)
deletedDrafts.add(draft) deletedDrafts.add(draft)
}
} }
fun restoreDraft(draft: DraftEntity) { fun restoreDraft(draft: DraftEntity) {
database.draftDao().insertOrReplace(draft) viewModelScope.launch {
.subscribe() database.draftDao().insertOrReplace(draft)
deletedDrafts.remove(draft) deletedDrafts.remove(draft)
}
} }
fun getToot(tootId: String): Single<Status> { fun getToot(tootId: String): Single<Status> {
@ -55,9 +65,10 @@ class DraftsViewModel @Inject constructor(
} }
override fun onCleared() { override fun onCleared() {
deletedDrafts.forEach { viewModelScope.launch {
draftHelper.deleteAttachments(it).subscribe() deletedDrafts.forEach {
draftHelper.deleteAttachments(it)
}
} }
} }
} }

View file

@ -15,30 +15,28 @@
package com.keylesspalace.tusky.db package com.keylesspalace.tusky.db
import androidx.paging.DataSource import androidx.paging.PagingSource
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
@Dao @Dao
interface DraftDao { interface DraftDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @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") @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") @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") @Query("DELETE FROM DraftEntity WHERE id = :id")
fun delete(id: Int): Completable suspend fun delete(id: Int)
@Query("SELECT * FROM DraftEntity WHERE id = :id") @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.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import dagger.android.AndroidInjection 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 kotlinx.parcelize.Parcelize
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
@ -49,6 +53,9 @@ class SendTootService : Service(), Injectable {
@Inject @Inject
lateinit var draftHelper: DraftHelper lateinit var draftHelper: DraftHelper
private val supervisorJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob)
private val tootsToSend = ConcurrentHashMap<Int, TootToSend>() private val tootsToSend = ConcurrentHashMap<Int, TootToSend>()
private val sendCalls = ConcurrentHashMap<Int, Call<Status>>() private val sendCalls = ConcurrentHashMap<Int, Call<Status>>()
@ -148,7 +155,6 @@ class SendTootService : Service(), Injectable {
newStatus newStatus
) )
sendCalls[tootId] = sendCall sendCalls[tootId] = sendCall
val callback = object : Callback<Status> { val callback = object : Callback<Status> {
@ -160,8 +166,9 @@ class SendTootService : Service(), Injectable {
if (response.isSuccessful) { if (response.isSuccessful) {
// If the status was loaded from a draft, delete the draft and associated media files. // If the status was loaded from a draft, delete the draft and associated media files.
if (tootToSend.draftId != 0) { if (tootToSend.draftId != 0) {
draftHelper.deleteDraftAndAttachments(tootToSend.draftId) serviceScope.launch {
.subscribe() draftHelper.deleteDraftAndAttachments(tootToSend.draftId)
}
} }
if (scheduled) { if (scheduled) {
@ -244,8 +251,8 @@ class SendTootService : Service(), Injectable {
} }
private fun saveTootToDrafts(toot: TootToSend) { private fun saveTootToDrafts(toot: TootToSend) {
serviceScope.launch {
draftHelper.saveDraft( draftHelper.saveDraft(
draftId = toot.draftId, draftId = toot.draftId,
accountId = toot.accountId, accountId = toot.accountId,
inReplyToId = toot.inReplyToId, inReplyToId = toot.inReplyToId,
@ -257,7 +264,8 @@ class SendTootService : Service(), Injectable {
mediaDescriptions = toot.mediaDescriptions, mediaDescriptions = toot.mediaDescriptions,
poll = toot.poll, poll = toot.poll,
failedToSend = true failedToSend = true
).subscribe() )
}
} }
private fun cancelSendingIntent(tootId: Int): PendingIntent { private fun cancelSendingIntent(tootId: Int): PendingIntent {
@ -269,6 +277,10 @@ class SendTootService : Service(), Injectable {
return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT) return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT)
} }
override fun onDestroy() {
super.onDestroy()
supervisorJob.cancel()
}
companion object { companion object {