diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 96eb1e34c..bb0ac6589 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -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 { @@ -218,18 +219,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje NotificationHelper.disablePullNotifications(this) } eventHub.events - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe { event: Event? -> - when (event) { - is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) - is MainTabsChangedEvent -> setupTabs(false) - is AnnouncementReadEvent -> { - unreadAnnouncementsCount-- - updateAnnouncementsBadge() - } + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { event: Event? -> + when (event) { + is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) + is MainTabsChangedEvent -> setupTabs(false) + is AnnouncementReadEvent -> { + unreadAnnouncementsCount-- + updateAnnouncementsBadge() } } + } Schedulers.io().scheduleDirect { // 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?) { if (animateAvatars) { glide.load(uri) - .placeholder(placeholder) - .into(imageView) + .placeholder(placeholder) + .into(imageView) } else { glide.asBitmap() - .load(uri) - .placeholder(placeholder) - .into(imageView) + .load(uri) + .placeholder(placeholder) + .into(imageView) } } @@ -367,114 +368,114 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje binding.mainDrawer.apply { tintStatusBar = true addItems( - primaryDrawerItem { - nameRes = R.string.action_edit_profile - iconicsIcon = GoogleMaterial.Icon.gmd_person - onClick = { - val intent = Intent(context, EditProfileActivity::class.java) - 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_edit_profile + iconicsIcon = GoogleMaterial.Icon.gmd_person + onClick = { + val intent = Intent(context, EditProfileActivity::class.java) + 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 + } ) if (addSearchButton) { binding.mainDrawer.addItemsAtPosition(4, - primaryDrawerItem { - nameRes = R.string.action_search - iconicsIcon = GoogleMaterial.Icon.gmd_search - onClick = { - startActivityWithSlideInAnimation(SearchActivity.getIntent(context)) - } - }) + primaryDrawerItem { + nameRes = R.string.action_search + iconicsIcon = GoogleMaterial.Icon.gmd_search + onClick = { + startActivityWithSlideInAnimation(SearchActivity.getIntent(context)) + } + }) } setSavedInstance(savedInstanceState) @@ -482,11 +483,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje if (BuildConfig.DEBUG) { binding.mainDrawer.addItems( - secondaryDrawerItem { - nameText = "debug" - isEnabled = false - textColor = ColorStateList.valueOf(Color.GREEN) - } + secondaryDrawerItem { + nameText = "debug" + isEnabled = false + textColor = ColorStateList.valueOf(Color.GREEN) + } ) } EmojiCompat.get().registerInitCallback(emojiInitCallback) @@ -519,7 +520,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje activeTabLayout.removeAllTabs() for (i in tabs.indices) { val tab = activeTabLayout.newTab() - .setIcon(tabs[i].icon) + .setIcon(tabs[i].icon) if (tabs[i].id == LIST) { tab.contentDescription = tabs[i].arguments[1] } else { @@ -611,168 +612,174 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun logout() { accountManager.activeAccount?.let { activeAccount -> AlertDialog.Builder(this) - .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) + .setTitle(R.string.action_logout) + .setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName)) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + 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() { - 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(navIconSize, navIconSize) { + ) +} - override fun onLoadStarted(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } - } +private fun onFetchUserInfoSuccess(me: Account) { + glide.asBitmap() + .load(me.header) + .into(header.accountHeaderBackground) - override fun onResourceReady(resource: Drawable, transition: Transition?) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize) - } + loadDrawerAvatar(me.avatar, false) - override fun onLoadCleared(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } - } - }) - } + accountManager.updateActiveAccount(me) + NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this) - 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) - } - ) - } + accountLocked = me.locked - private fun updateAnnouncementsBadge() { - binding.mainDrawer.updateBadge(DRAWER_ITEM_ANNOUNCEMENTS, StringHolder(if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString())) - } + updateProfiles() + updateShortcut(this, accountManager.activeAccount!!) +} - private fun updateProfiles() { - val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - val profiles: MutableList = accountManager.getAllAccountsOrderedByActive().map { acc -> - val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header, animateEmojis)) +private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { + val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) - 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 + glide.asDrawable() + .load(avatarUrl) + .transform( + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) + ) + .apply { + if (showPlaceholder) { + placeholder(R.drawable.avatar_default) } } - header.clear() - header.profiles = profiles - header.setActiveProfile(accountManager.activeAccount!!.id) + .into(object : CustomTarget(navIconSize, navIconSize) { + + override fun onLoadStarted(placeholder: Drawable?) { + if (placeholder != null) { + binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) + } + } + + override fun onResourceReady(resource: Drawable, transition: Transition?) { + 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 = 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 { - 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" - } +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 { return PrimaryDrawerItem() - .apply { - isSelectable = false - isIconTinted = true - } - .apply(block) + .apply { + isSelectable = false + isIconTinted = true + } + .apply(block) } private inline fun secondaryDrawerItem(block: SecondaryDrawerItem.() -> Unit): SecondaryDrawerItem { return SecondaryDrawerItem() - .apply { - isSelectable = false - isIconTinted = true - } - .apply(block) + .apply { + isSelectable = false + isIconTinted = true + } + .apply(block) } private var AbstractDrawerItem<*, *>.onClick: () -> Unit diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 2e38fbe84..7fffa68d2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -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,22 +216,23 @@ class ComposeViewModel @Inject constructor( } fun deleteDraft() { - if (draftId != 0) { - draftHelper.deleteDraftAndAttachments(draftId) - .subscribe() + viewModelScope.launch { + if (draftId != 0) { + draftHelper.deleteDraftAndAttachments(draftId) + } } } fun saveDraft(content: String, contentWarning: String) { + viewModelScope.launch { + val mediaUris: MutableList = mutableListOf() + val mediaDescriptions: MutableList = mutableListOf() + media.value?.forEach { item -> + mediaUris.add(item.uri.toString()) + mediaDescriptions.add(item.description) + } - val mediaUris: MutableList = mutableListOf() - val mediaDescriptions: MutableList = mutableListOf() - media.value?.forEach { item -> - mediaUris.add(item.uri.toString()) - mediaDescriptions.add(item.description) - } - - draftHelper.saveDraft( + draftHelper.saveDraft( draftId = draftId, accountId = accountManager.activeAccount?.id!!, inReplyToId = inReplyToId, @@ -241,7 +244,8 @@ class ComposeViewModel @Inject constructor( mediaDescriptions = mediaDescriptions, poll = poll.value, failedToSend = false - ).subscribe() + ) + } } /** diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt index e9fe72383..82a7dae50 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -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, 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) - } - } + suspend fun deleteDraftAndAttachments(draftId: Int) { + draftDao.find(draftId)?.let { draft -> + deleteDraftAndAttachments(draft) + } } - fun deleteDraftAndAttachments(draft: DraftEntity): Completable { - return deleteAttachments(draft) - .andThen(draftDao.delete(draft.id)) + suspend fun deleteDraftAndAttachments(draft: DraftEntity) { + deleteAttachments(draft) + draftDao.delete(draft.id) } - fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) { - draftDao.loadDraftsSingle(accountId) - .flatMapObservable { Observable.fromIterable(it) } - .flatMapCompletable { draft -> - deleteDraftAndAttachments(draft) - }.subscribeOn(Schedulers.io()) - .subscribe() + suspend fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) { + draftDao.loadDrafts(accountId).forEach { draft -> + deleteDraftAndAttachments(draft) + } } - fun deleteAttachments(draft: DraftEntity): Completable { - return Completable.fromCallable { + 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 { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt index e70050160..8ca00491a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -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 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) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt index 5ba3716eb..42c93b0b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt @@ -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>( +) : PagingDataAdapter>( object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { return oldItem.id == newItem.id @@ -87,6 +87,5 @@ class DraftsAdapter( holder.binding.draftPoll.hide() } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt index aaf878153..78853d1e5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt @@ -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,22 +36,28 @@ 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 = 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 - database.draftDao().delete(draft.id) - .subscribe() - deletedDrafts.add(draft) + viewModelScope.launch { + database.draftDao().delete(draft.id) + deletedDrafts.add(draft) + } } fun restoreDraft(draft: DraftEntity) { - database.draftDao().insertOrReplace(draft) - .subscribe() - deletedDrafts.remove(draft) + viewModelScope.launch { + database.draftDao().insertOrReplace(draft) + deletedDrafts.remove(draft) + } } fun getToot(tootId: String): Single { @@ -55,9 +65,10 @@ class DraftsViewModel @Inject constructor( } override fun onCleared() { - deletedDrafts.forEach { - draftHelper.deleteAttachments(it).subscribe() + viewModelScope.launch { + deletedDrafts.forEach { + draftHelper.deleteAttachments(it) + } } } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt index 5e6f21b4c..88f683d8a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt @@ -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 + fun draftsPagingSource(accountId: Long): PagingSource @Query("SELECT * FROM DraftEntity WHERE accountId = :accountId") - fun loadDraftsSingle(accountId: Long): Single> + suspend fun loadDrafts(accountId: Long): List @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 + suspend fun find(id: Int): DraftEntity? } 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 460c83e5e..20ae8f476 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt @@ -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() private val sendCalls = ConcurrentHashMap>() @@ -148,7 +155,6 @@ class SendTootService : Service(), Injectable { newStatus ) - sendCalls[tootId] = sendCall val callback = object : Callback { @@ -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) { - draftHelper.deleteDraftAndAttachments(tootToSend.draftId) - .subscribe() + serviceScope.launch { + draftHelper.deleteDraftAndAttachments(tootToSend.draftId) + } } if (scheduled) { @@ -244,8 +251,8 @@ class SendTootService : Service(), Injectable { } private fun saveTootToDrafts(toot: TootToSend) { - - draftHelper.saveDraft( + serviceScope.launch { + draftHelper.saveDraft( draftId = toot.draftId, accountId = toot.accountId, inReplyToId = toot.inReplyToId, @@ -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 {