Yuito-app-android/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt

1363 lines
57 KiB
Kotlin

/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.PorterDuff
import android.graphics.drawable.Animatable
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.InsetDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.util.TypedValue
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.MenuItem.SHOW_AS_ACTION_NEVER
import android.view.View
import android.view.WindowManager
import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.widget.PopupMenu
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.view.GravityCompat
import androidx.core.view.MenuProvider
import androidx.core.view.forEach
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.MarginPageTransformer
import at.connyduck.calladapter.networkresult.fold
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.FixedSizeDrawable
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.color.MaterialColors
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
import com.google.android.material.tabs.TabLayoutMediator
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.CacheUpdater
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.notifications.disableAllNotifications
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.trending.TrendingActivity
import com.keylesspalace.tusky.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.FabFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.ResettableFragment
import com.keylesspalace.tusky.pager.MainPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase
import com.keylesspalace.tusky.usecase.LogoutUsecase
import com.keylesspalace.tusky.util.deleteStaleCachedMedia
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getDimension
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
import com.keylesspalace.tusky.util.setDrawableTint
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.updateShortcut
import com.keylesspalace.tusky.util.viewBinding
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import com.mikepenz.materialdrawer.holder.BadgeStyle
import com.mikepenz.materialdrawer.holder.ColorHolder
import com.mikepenz.materialdrawer.holder.StringHolder
import com.mikepenz.materialdrawer.iconics.iconicsIcon
import com.mikepenz.materialdrawer.model.AbstractDrawerItem
import com.mikepenz.materialdrawer.model.DividerDrawerItem
import com.mikepenz.materialdrawer.model.PrimaryDrawerItem
import com.mikepenz.materialdrawer.model.ProfileDrawerItem
import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem
import com.mikepenz.materialdrawer.model.SecondaryDrawerItem
import com.mikepenz.materialdrawer.model.interfaces.IProfile
import com.mikepenz.materialdrawer.model.interfaces.descriptionRes
import com.mikepenz.materialdrawer.model.interfaces.descriptionText
import com.mikepenz.materialdrawer.model.interfaces.iconRes
import com.mikepenz.materialdrawer.model.interfaces.iconUrl
import com.mikepenz.materialdrawer.model.interfaces.nameRes
import com.mikepenz.materialdrawer.model.interfaces.nameText
import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader
import com.mikepenz.materialdrawer.util.DrawerImageLoader
import com.mikepenz.materialdrawer.util.addItems
import com.mikepenz.materialdrawer.util.addItemsAtPosition
import com.mikepenz.materialdrawer.util.addStickyDrawerItems
import com.mikepenz.materialdrawer.util.updateBadge
import com.mikepenz.materialdrawer.widget.AccountHeaderView
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import net.accelf.yuito.FooterDrawerItem
import net.accelf.yuito.QuickTootViewModel
import net.accelf.yuito.streaming.StreamingManager
import javax.inject.Inject
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, MenuProvider {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var cacheUpdater: CacheUpdater
@Inject
lateinit var logoutUsecase: LogoutUsecase
@Inject
lateinit var draftsAlert: DraftsAlert
@Inject
lateinit var developerToolsUseCase: DeveloperToolsUseCase
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var streamingManager: StreamingManager
private val binding by viewBinding(ActivityMainBinding::inflate)
private val quickTootViewModel: QuickTootViewModel by viewModels { viewModelFactory }
private lateinit var header: AccountHeaderView
private var onTabSelectedListener: OnTabSelectedListener? = null
private var unreadAnnouncementsCount = 0
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
private lateinit var glide: RequestManager
// We need to know if the emoji pack has been changed
private var selectedEmojiPack: String? = null
/** Mediate between binding.viewPager and the chosen tab layout */
private var tabLayoutMediator: TabLayoutMediator? = null
/** Adapter for the different timeline tabs */
private lateinit var tabAdapter: MainPagerAdapter
private var directMessageTab: TabLayout.Tab? = null
@Suppress("DEPRECATION")
@SuppressLint("RestrictedApi")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val activeAccount = accountManager.activeAccount
?: return // will be redirected to LoginActivity by BaseActivity
var showNotificationTab = false
// check for savedInstanceState in order to not handle intent events more than once
if (intent != null && savedInstanceState == null) {
val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1)
if (notificationId != -1) {
// opened from a notification action, cancel the notification
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(intent.getStringExtra(NOTIFICATION_TAG), notificationId)
}
/** there are two possibilities the accountId can be passed to MainActivity:
* - from our code as Long Intent Extra TUSKY_ACCOUNT_ID
* - from share shortcuts as String 'android.intent.extra.shortcut.ID'
*/
var tuskyAccountId = intent.getLongExtra(TUSKY_ACCOUNT_ID, -1)
if (tuskyAccountId == -1L) {
val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)
if (accountIdString != null) {
tuskyAccountId = accountIdString.toLong()
}
}
val accountRequested = tuskyAccountId != -1L
if (accountRequested && tuskyAccountId != activeAccount.id) {
accountManager.setActiveAccount(tuskyAccountId)
}
val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false)
if (canHandleMimeType(intent.type) || intent.hasExtra(COMPOSE_OPTIONS)) {
// Sharing to Tusky from an external app
if (accountRequested) {
// The correct account is already active
forwardToComposeActivity(intent)
} else {
// No account was provided, show the chooser
showAccountChooserDialog(
getString(R.string.action_share_as),
true,
object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) {
val requestedId = account.id
if (requestedId == activeAccount.id) {
// The correct account is already active
forwardToComposeActivity(intent)
} else {
// A different account was requested, restart the activity
intent.putExtra(TUSKY_ACCOUNT_ID, requestedId)
changeAccount(requestedId, intent)
}
}
}
)
}
} else if (openDrafts) {
val intent = DraftsActivity.newIntent(this)
startActivity(intent)
} else if (accountRequested && intent.hasExtra(NOTIFICATION_TYPE)) {
// user clicked a notification, show follow requests for type FOLLOW_REQUEST,
// otherwise show notification tab
if (intent.getStringExtra(NOTIFICATION_TYPE) == Notification.Type.FOLLOW_REQUEST.name) {
val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS)
startActivityWithSlideInAnimation(intent)
} else {
showNotificationTab = true
}
}
}
window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own
setContentView(binding.root)
glide = Glide.with(this)
binding.viewQuickToot.attachViewModel(quickTootViewModel, this)
binding.composeButton.setOnClickListener(binding.viewQuickToot::onFABClicked)
// Determine which of the three toolbars should be the supportActionBar (which hosts
// the options menu).
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
if (hideTopToolbar) {
when (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top")) {
"top" -> setSupportActionBar(binding.topNav)
"bottom" -> setSupportActionBar(binding.bottomNav)
}
binding.mainToolbar.hide()
// There's not enough space in the top/bottom bars to show the title as well.
supportActionBar?.setDisplayShowTitleEnabled(false)
} else {
setSupportActionBar(binding.mainToolbar)
binding.mainToolbar.show()
}
loadDrawerAvatar(activeAccount.profilePictureUrl, true)
addMenuProvider(this)
binding.viewPager.reduceSwipeSensitivity()
setupDrawer(
savedInstanceState,
addSearchButton = hideTopToolbar,
addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_TAGS),
addTrendingStatusesButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_STATUSES),
)
/* Fetch user info while we're doing other things. This has to be done after setting up the
* drawer, though, because its callback touches the header in the drawer. */
fetchUserInfo()
fetchAnnouncements()
// Initialise the tab adapter and set to viewpager. Fragments appear to be leaked if the
// adapter changes over the life of the viewPager (the adapter, not its contents), so set
// the initial list of tabs to empty, and set the full list later in setupTabs(). See
// https://github.com/tuskyapp/Tusky/issues/3251 for details.
tabAdapter = MainPagerAdapter(emptyList(), this)
binding.viewPager.adapter = tabAdapter
setupTabs(showNotificationTab)
if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean(PrefKeys.VIEW_PAGER_OFF_SCREEN_LIMIT, false)) {
binding.viewPager.offscreenPageLimit = 9
}
lifecycleScope.launch {
eventHub.events.collect { event ->
when (event) {
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
is MainTabsChangedEvent -> {
refreshMainDrawerItems(
addSearchButton = hideTopToolbar,
addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS),
addTrendingStatusesButton = !event.newTabs.hasTab(TRENDING_STATUSES),
)
setupTabs(false)
}
is AnnouncementReadEvent -> {
unreadAnnouncementsCount--
updateAnnouncementsBadge()
}
is NewNotificationsEvent -> {
directMessageTab?.let { tab ->
if (event.accountId == activeAccount.accountId) {
val hasDirectMessageNotification =
event.notifications.any { it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT }
if (hasDirectMessageNotification) {
showDirectMessageBadge(true)
}
}
}
}
is NotificationsLoadingEvent -> {
if (event.accountId == activeAccount.accountId) {
showDirectMessageBadge(false)
}
}
is ConversationsLoadingEvent -> {
if (event.accountId == activeAccount.accountId) {
showDirectMessageBadge(false)
}
}
}
binding.viewQuickToot.handleEvent(event)
}
}
Schedulers.io().scheduleDirect {
// Flush old media that was cached for sharing
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
}
selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
when {
binding.mainDrawerLayout.isOpen -> {
binding.mainDrawerLayout.close()
}
binding.viewPager.currentItem != 0 -> {
binding.viewPager.currentItem = 0
}
else -> {
finish()
}
}
}
}
)
if (
Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
1
)
}
// "Post failed" dialog should display in this activity
draftsAlert.observeInContext(this, true)
}
private fun showDirectMessageBadge(showBadge: Boolean) {
directMessageTab?.let { tab ->
tab.badge?.isVisible = showBadge
// TODO a bit cumbersome (also for resetting)
accountManager.activeAccount?.let {
if (it.hasDirectMessageBadge != showBadge) {
it.hasDirectMessageBadge = showBadge
accountManager.saveAccount(it)
}
}
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.activity_main, menu)
menu.findItem(R.id.action_search)?.apply {
icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary)
}
}
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
// If the main toolbar is hidden then there's no space in the top/bottomNav to show
// the menu items as icons, so forceably disable them
if (!binding.mainToolbar.isVisible) menu.forEach { it.setShowAsAction(SHOW_AS_ACTION_NEVER) }
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_search -> {
startActivity(SearchActivity.getIntent(this@MainActivity))
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onResume() {
super.onResume()
val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
if (currentEmojiPack != selectedEmojiPack) {
Log.d(
TAG,
"onResume: EmojiPack has been changed from %s to %s"
.format(selectedEmojiPack, currentEmojiPack)
)
selectedEmojiPack = currentEmojiPack
recreate()
}
}
override fun onStart() {
super.onStart()
// For some reason the navigation drawer is opened when the activity is recreated
if (binding.mainDrawerLayout.isOpen) {
binding.mainDrawerLayout.closeDrawer(GravityCompat.START, false)
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
when (keyCode) {
KeyEvent.KEYCODE_MENU -> {
if (binding.mainDrawerLayout.isOpen) {
binding.mainDrawerLayout.close()
} else {
binding.mainDrawerLayout.open()
}
return true
}
KeyEvent.KEYCODE_SEARCH -> {
startActivityWithSlideInAnimation(SearchActivity.getIntent(this))
return true
}
}
if (event.isCtrlPressed || event.isShiftPressed) {
// FIXME: blackberry keyONE raises SHIFT key event even CTRL IS PRESSED
when (keyCode) {
KeyEvent.KEYCODE_N -> {
// open compose activity by pressing SHIFT + N (or CTRL + N)
val composeIntent = Intent(applicationContext, ComposeActivity::class.java)
startActivity(composeIntent)
return true
}
}
}
return super.onKeyDown(keyCode, event)
}
public override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
if (intent != null) {
val redirectUrl = intent.getStringExtra(REDIRECT_URL)
if (redirectUrl != null) {
viewUrl(redirectUrl, PostLookupFallbackBehavior.DISPLAY_ERROR)
}
}
}
private fun forwardToComposeActivity(intent: Intent) {
val composeOptions = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS, ComposeActivity.ComposeOptions::class.java)
val composeIntent = if (composeOptions != null) {
ComposeActivity.startIntent(this, composeOptions)
} else {
Intent(this, ComposeActivity::class.java).apply {
action = intent.action
type = intent.type
putExtras(intent)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
startActivity(composeIntent)
finish()
}
private fun setupDrawer(
savedInstanceState: Bundle?,
addSearchButton: Boolean,
addTrendingTagsButton: Boolean,
addTrendingStatusesButton: Boolean,
) {
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener)
binding.topNav.setNavigationOnClickListener(drawerOpenClickListener)
binding.bottomNav.setNavigationOnClickListener(drawerOpenClickListener)
header = AccountHeaderView(this).apply {
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
currentHiddenInList = true
onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean -> handleProfileClick(profile, current) }
addProfile(
ProfileSettingDrawerItem().apply {
identifier = DRAWER_ITEM_ADD_ACCOUNT
nameRes = R.string.add_account_name
descriptionRes = R.string.add_account_description
iconicsIcon = GoogleMaterial.Icon.gmd_add
},
0
)
attachToSliderView(binding.mainDrawer)
dividerBelowHeader = false
closeDrawerOnProfileListClick = true
}
header.currentProfileName.maxLines = 1
header.currentProfileName.ellipsize = TextUtils.TruncateAt.END
header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter))
header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent))
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
if (animateAvatars) {
glide.load(uri)
.placeholder(placeholder)
.into(imageView)
} else {
glide.asBitmap()
.load(uri)
.placeholder(placeholder)
.into(imageView)
}
}
override fun cancel(imageView: ImageView) {
glide.clear(imageView)
}
override fun placeholder(ctx: Context, tag: String?): Drawable {
if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) {
return AppCompatResources.getDrawable(ctx, R.drawable.avatar_default)!!
}
return super.placeholder(ctx, tag)
}
})
binding.mainDrawer.apply {
refreshMainDrawerItems(
addSearchButton = addSearchButton,
addTrendingTagsButton = addTrendingTagsButton,
addTrendingStatusesButton = addTrendingStatusesButton,
)
setSavedInstance(savedInstanceState)
}
}
private fun refreshMainDrawerItems(
addSearchButton: Boolean,
addTrendingTagsButton: Boolean,
addTrendingStatusesButton: Boolean,
) {
binding.mainDrawer.apply {
itemAdapter.clear()
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)
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_posts
iconRes = R.drawable.ic_access_time
onClick = {
startActivityWithSlideInAnimation(ScheduledStatusActivity.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(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorOnPrimary))
color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.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
}
)
val footer = FooterDrawerItem()
addStickyDrawerItems(footer)
lifecycleScope.launch {
footer.setInstance(mastodonApi.getInstance())
}
if (addSearchButton) {
binding.mainDrawer.addItemsAtPosition(
4,
primaryDrawerItem {
nameRes = R.string.action_search
iconicsIcon = GoogleMaterial.Icon.gmd_search
onClick = {
startActivityWithSlideInAnimation(SearchActivity.getIntent(context))
}
}
)
}
if (addTrendingTagsButton) {
binding.mainDrawer.addItemsAtPosition(
5,
primaryDrawerItem {
nameRes = R.string.title_public_trending_hashtags
iconicsIcon = GoogleMaterial.Icon.gmd_trending_up
onClick = {
startActivityWithSlideInAnimation(TrendingActivity.getIntent(context))
}
}
)
}
if (addTrendingStatusesButton) {
binding.mainDrawer.addItemsAtPosition(
6,
primaryDrawerItem {
nameRes = R.string.title_public_trending_statuses
iconicsIcon = GoogleMaterial.Icon.gmd_local_fire_department
onClick = {
startActivityWithSlideInAnimation(StatusListActivity.newTrendingIntent(context))
}
}
)
}
}
if (BuildConfig.DEBUG) {
// Add a "Developer tools" entry. Code that makes it easier to
// set the app state at runtime belongs here, it will never
// be exposed to users.
binding.mainDrawer.addItems(
DividerDrawerItem(),
secondaryDrawerItem {
nameText = "Developer tools"
isEnabled = true
iconicsIcon = GoogleMaterial.Icon.gmd_developer_mode
onClick = {
buildDeveloperToolsDialog().show()
}
}
)
}
binding.mainDrawer.addItems(
secondaryDrawerItem {
nameText = "Yuito (by kyori19)"
isEnabled = false
textColor = ColorStateList.valueOf(MaterialColors.getColor(binding.mainDrawer, R.attr.colorInfo))
}
)
}
private fun buildDeveloperToolsDialog(): AlertDialog {
return AlertDialog.Builder(this)
.setTitle("Developer Tools")
.setItems(
arrayOf("Create \"Load more\" gap")
) { _, which ->
Log.d(TAG, "Developer tools: $which")
when (which) {
0 -> {
Log.d(TAG, "Creating \"Load more\" gap")
lifecycleScope.launch {
accountManager.activeAccount?.let {
developerToolsUseCase.createLoadMoreGap(
it.id
)
}
}
}
}
}
.create()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(binding.mainDrawer.saveInstanceState(outState))
}
private fun setupTabs(selectNotificationTab: Boolean) {
val activeTabLayout = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") {
val actionBarSize = getDimension(this, androidx.appcompat.R.attr.actionBarSize)
val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin)
(binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin
binding.topNav.hide()
binding.bottomTabLayout
} else {
binding.bottomNav.hide()
(binding.viewPager.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = 0
(binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).anchorId = R.id.viewPager
binding.tabLayout
}
// Save the previous tab so it can be restored later
val previousTab = tabAdapter.tabs.getOrNull(binding.viewPager.currentItem)
val tabs = accountManager.activeAccount!!.tabPreferences.toMutableList()
val popups = MutableList<PopupMenu?>(tabs.size) { null }
// Detach any existing mediator before changing tab contents and attaching a new mediator
tabLayoutMediator?.detach()
directMessageTab = null
tabAdapter.tabs = tabs
tabAdapter.notifyItemRangeChanged(0, tabs.size)
tabLayoutMediator = TabLayoutMediator(activeTabLayout, binding.viewPager, true) {
tab: TabLayout.Tab, position: Int ->
val data = tabs[position]
tab.icon = AppCompatResources.getDrawable(this@MainActivity, data.icon)
tab.contentDescription = when (data.id) {
LIST -> data.arguments[1]
else -> getString(data.text)
}
if (popups[position] != null) {
return@TabLayoutMediator
}
if (tabs[position].id == DIRECT) {
tab.orCreateBadge
tab.badge?.isVisible = accountManager.activeAccount?.hasDirectMessageBadge ?: false
directMessageTab = tab
}
val popup = PopupMenu(this, tab.view)
popup.menuInflater.inflate(R.menu.view_tab_action, popup.menu)
@SuppressLint("RestrictedApi")
if (popup.menu is MenuBuilder) {
val menuBuilder = popup.menu as MenuBuilder
if (data.id in arrayOf(HOME, LOCAL, FEDERATED, HASHTAG, LIST)) {
menuBuilder.findItem(R.id.tabReset).isVisible = true
}
if (data.id == LIST) {
menuBuilder.findItem(R.id.tabEditList).isVisible = true
}
if (data.id == NOTIFICATIONS) {
menuBuilder.findItem(R.id.tabToggleNotificationsFilter).isVisible = true
}
menuBuilder.setOptionalIconsVisible(true)
menuBuilder.visibleItems.forEach { item ->
val iconMarginPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics).toInt()
if (item.icon != null) {
item.icon = InsetDrawable(item.icon, iconMarginPx, 0, iconMarginPx, 0)
setDrawableTint(this, item.icon!!, android.R.attr.textColorPrimary)
}
}
}
popup.setOnMenuItemClickListener { item ->
val fragment = tabAdapter.getFragment(tab.position)
when (item.itemId) {
R.id.tabJumpToTop -> {
if (fragment is ReselectableFragment) {
(fragment as ReselectableFragment).onReselect()
}
refreshComposeButtonState(tabAdapter, tab.position)
}
R.id.tabReset -> {
if (fragment is ResettableFragment) {
fragment.onReset()
}
}
R.id.tabEditList -> {
AccountsInListFragment.newInstance(
data.arguments.getOrNull(0).orEmpty(),
data.arguments.getOrNull(1).orEmpty()
).show(supportFragmentManager, null)
}
R.id.tabToggleNotificationsFilter -> {
if (fragment is NotificationsFragment) {
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
prefs.edit().putBoolean(
"showNotificationsFilter",
!prefs.getBoolean("showNotificationsFilter", true)
)
.apply()
lifecycleScope.launch {
eventHub.dispatch(PreferenceChangedEvent("showNotificationsFilter"))
}
}
}
}
false
}
popups[position] = popup
}.also { it.attach() }
// Selected tab is either
// - Notification tab (if appropriate)
// - The previously selected tab (if it hasn't been removed)
// - Left-most tab
val position = if (selectNotificationTab) {
tabs.indexOfFirst { it.id == NOTIFICATIONS }
} else {
previousTab?.let { tabs.indexOfFirst { it == previousTab } }
}.takeIf { it != -1 } ?: 0
binding.viewPager.setCurrentItem(position, false)
val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin)
binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin))
val enableSwipeForTabs = preferences.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true)
binding.viewPager.isUserInputEnabled = enableSwipeForTabs
onTabSelectedListener?.let {
activeTabLayout.removeOnTabSelectedListener(it)
}
onTabSelectedListener = object : OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
binding.mainToolbar.title = tab.contentDescription
refreshComposeButtonState(tabAdapter, tab.position)
if (tab == directMessageTab) {
tab.badge?.isVisible = false
accountManager.activeAccount?.let {
if (it.hasDirectMessageBadge) {
it.hasDirectMessageBadge = false
accountManager.saveAccount(it)
}
}
}
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
override fun onTabReselected(tab: TabLayout.Tab) {
popups[tab.position]?.show()
}
}.also {
activeTabLayout.addOnTabSelectedListener(it)
}
supportActionBar?.title = tabs[position].title(this@MainActivity)
binding.mainToolbar.setOnClickListener {
(tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
}
updateProfiles()
streamingManager.setup(
this,
tabs.mapNotNull { it.subscription }.toSet(),
) { active ->
if (active) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
private fun refreshComposeButtonState(adapter: MainPagerAdapter, tabPosition: Int) {
adapter.getFragment(tabPosition)?.also { fragment ->
if (fragment is FabFragment) {
if (fragment.isFabVisible()) {
binding.composeButton.show()
} else {
binding.composeButton.hide()
}
} else {
binding.composeButton.show()
}
}
}
private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean {
val activeAccount = accountManager.activeAccount
// open profile when active image was clicked
if (current && activeAccount != null) {
val intent = AccountActivity.getIntent(this, activeAccount.accountId)
startActivityWithSlideInAnimation(intent)
return false
}
// open LoginActivity to add new account
if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) {
startActivityWithSlideInAnimation(LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN))
return false
}
// change Account
changeAccount(profile.identifier, null)
return false
}
private fun changeAccount(newSelectedId: Long, forward: Intent?) {
cacheUpdater.stop()
accountManager.setActiveAccount(newSelectedId)
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
if (forward != null) {
intent.type = forward.type
intent.action = forward.action
intent.putExtras(forward)
}
startActivity(intent)
finishWithoutSlideOutAnimation()
overridePendingTransition(R.anim.explode, R.anim.explode)
}
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 ->
binding.appBar.hide()
binding.viewPager.hide()
binding.progressBar.show()
binding.bottomNav.hide()
binding.composeButton.hide()
lifecycleScope.launch {
val otherAccountAvailable = logoutUsecase.logout()
val intent = if (otherAccountAvailable) {
Intent(this@MainActivity, MainActivity::class.java)
} else {
LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT)
}
startActivity(intent)
finishWithoutSlideOutAnimation()
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
private fun fetchUserInfo() = lifecycleScope.launch {
mastodonApi.accountVerifyCredentials().fold(
{ 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)
// Setup push notifications
showMigrationNoticeIfNecessary(this, binding.mainCoordinatorLayout, binding.composeButton, accountManager)
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
lifecycleScope.launch {
enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager)
}
} else {
disableAllNotifications(this, accountManager)
}
updateProfiles()
updateShortcut(this, accountManager.activeAccount!!)
}
@SuppressLint("CheckResult")
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
val activeToolbar = if (hideTopToolbar) {
val navOnBottom = preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom"
if (navOnBottom) {
binding.bottomNav
} else {
binding.topNav
}
} else {
binding.mainToolbar
}
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
if (animateAvatars) {
glide.asDrawable().load(avatarUrl).transform(RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)))
.apply {
if (showPlaceholder) placeholder(R.drawable.avatar_default)
}
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
}
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable>?
) {
if (resource is Animatable) resource.start()
activeToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
}
override fun onLoadCleared(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
}
})
} else {
glide.asBitmap().load(avatarUrl).transform(RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)))
.apply {
if (showPlaceholder) placeholder(R.drawable.avatar_default)
}
.into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
}
override fun onResourceReady(
resource: Bitmap,
transition: Transition<in Bitmap>?
) {
activeToolbar.navigationIcon = FixedSizeDrawable(
BitmapDrawable(resources, resource),
navIconSize,
navIconSize
)
}
override fun onLoadCleared(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
}
})
}
}
private fun fetchAnnouncements() {
lifecycleScope.launch {
mastodonApi.listAnnouncements(false)
.fold(
{ announcements ->
unreadAnnouncementsCount = announcements.count { !it.read }
updateAnnouncementsBadge()
},
{ throwable ->
Log.w(TAG, "Failed to fetch announcements.", throwable)
}
)
}
}
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 ->
ProfileDrawerItem().apply {
isSelected = acc.isActive
nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
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)
binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername(this)) {
accountManager.activeAccount!!.fullName
} else {
null
}
}
override fun getActionButton() = binding.composeButton
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
private const val REDIRECT_URL = "redirectUrl"
private const val OPEN_DRAFTS = "draft"
private const val TUSKY_ACCOUNT_ID = "tuskyAccountId"
private const val COMPOSE_OPTIONS = "composeOptions"
private const val NOTIFICATION_TYPE = "notificationType"
private const val NOTIFICATION_TAG = "notificationTag"
private const val NOTIFICATION_ID = "notificationId"
/**
* Switches the active account to the provided accountId and then stays on MainActivity
*/
@JvmStatic
fun accountSwitchIntent(context: Context, tuskyAccountId: Long): Intent {
return Intent(context, MainActivity::class.java).apply {
putExtra(TUSKY_ACCOUNT_ID, tuskyAccountId)
}
}
/**
* Switches the active account to the accountId and takes the user to the correct place according to the notification they clicked
*/
@JvmStatic
fun openNotificationIntent(context: Context, tuskyAccountId: Long, type: Notification.Type): Intent {
return accountSwitchIntent(context, tuskyAccountId).apply {
putExtra(NOTIFICATION_TYPE, type.name)
}
}
/**
* Switches the active account to the accountId and then opens ComposeActivity with the provided options
* @param tuskyAccountId the id of the Tusky account to open the screen with. Set to -1 for current account.
* @param notificationId optional id of the notification that should be cancelled when this intent is opened
* @param notificationTag optional tag of the notification that should be cancelled when this intent is opened
*/
@JvmStatic
fun composeIntent(
context: Context,
options: ComposeActivity.ComposeOptions,
tuskyAccountId: Long = -1,
notificationTag: String? = null,
notificationId: Int = -1
): Intent {
return accountSwitchIntent(context, tuskyAccountId).apply {
action = Intent.ACTION_SEND // so it can be opened via shortcuts
putExtra(COMPOSE_OPTIONS, options)
putExtra(NOTIFICATION_TAG, notificationTag)
putExtra(NOTIFICATION_ID, notificationId)
}
}
/**
* switches the active account to the accountId and then tries to resolve and show the provided url
*/
@JvmStatic
fun redirectIntent(context: Context, tuskyAccountId: Long, url: String): Intent {
return accountSwitchIntent(context, tuskyAccountId).apply {
putExtra(REDIRECT_URL, url)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
/**
* switches the active account to the provided accountId and then opens drafts
*/
fun draftIntent(context: Context, tuskyAccountId: Long): Intent {
return accountSwitchIntent(context, tuskyAccountId).apply {
putExtra(OPEN_DRAFTS, true)
}
}
}
}
private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem {
return PrimaryDrawerItem()
.apply {
isSelectable = false
isIconTinted = true
}
.apply(block)
}
private inline fun secondaryDrawerItem(block: SecondaryDrawerItem.() -> Unit): SecondaryDrawerItem {
return SecondaryDrawerItem()
.apply {
isSelectable = false
isIconTinted = true
}
.apply(block)
}
private var AbstractDrawerItem<*, *>.onClick: () -> Unit
get() = throw UnsupportedOperationException()
set(value) {
onDrawerItemClickListener = { _, _, _ ->
value()
false
}
}