diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt new file mode 100644 index 00000000..99f7b64f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -0,0 +1,655 @@ +/* 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 . */ + +package com.keylesspalace.tusky + +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.res.ColorStateList +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.view.KeyEvent +import android.view.MenuItem +import android.view.View +import android.widget.ImageView +import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.emoji.text.EmojiCompat +import androidx.emoji.text.EmojiCompat.InitCallback +import androidx.lifecycle.Lifecycle +import androidx.preference.PreferenceManager +import androidx.viewpager2.widget.MarginPageTransformer +import com.bumptech.glide.Glide +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayout.OnTabSelectedListener +import com.google.android.material.tabs.TabLayoutMediator +import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy +import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType +import com.keylesspalace.tusky.components.conversation.ConversationsRepository +import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity +import com.keylesspalace.tusky.components.search.SearchActivity +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 +import com.keylesspalace.tusky.pager.MainPagerAdapter +import com.keylesspalace.tusky.util.* +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.materialdrawer.iconics.iconicsIcon +import com.mikepenz.materialdrawer.model.* +import com.mikepenz.materialdrawer.model.interfaces.* +import com.mikepenz.materialdrawer.util.* +import com.mikepenz.materialdrawer.widget.AccountHeaderView +import com.uber.autodispose.android.lifecycle.autoDispose +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import io.reactivex.android.schedulers.AndroidSchedulers +import kotlinx.android.synthetic.main.activity_main.* +import javax.inject.Inject + +class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector { + @Inject + lateinit var androidInjector: DispatchingAndroidInjector + + @Inject + lateinit var eventHub: EventHub + + @Inject + lateinit var cacheUpdater: CacheUpdater + + @Inject + lateinit var conversationRepository: ConversationsRepository + + private lateinit var header: AccountHeaderView + private lateinit var drawerToggle: ActionBarDrawerToggle + + private var notificationTabPosition = 0 + + private var adapter: MainPagerAdapter? = null + + private val emojiInitCallback = object : InitCallback() { + override fun onInitialized() { + if (!isDestroyed) { + updateProfiles() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (accountManager.activeAccount == null) { + // will be redirected to LoginActivity by BaseActivity + return + } + var showNotificationTab = false + if (intent != null) { + /** there are two possibilities the accountId can be passed to MainActivity: + * - from our code as long 'account_id' + * - from share shortcuts as String 'android.intent.extra.shortcut.ID' + */ + var accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1) + if (accountId == -1L) { + val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) + if (accountIdString != null) { + accountId = accountIdString.toLong() + } + } + val accountRequested = accountId != -1L + if (accountRequested) { + val account = accountManager.activeAccount + if (account == null || accountId != account.id) { + accountManager.setActiveAccount(accountId) + } + } + if (canHandleMimeType(intent.type)) { + // Sharing to Tusky from an external app + if (accountRequested) { + // The correct account is already active + forwardShare(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 + val activeAccount = accountManager.activeAccount + if (activeAccount != null && requestedId == activeAccount.id) { + // The correct account is already active + forwardShare(intent) + } else { + // A different account was requested, restart the activity + intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId) + changeAccount(requestedId, intent) + } + } + }) + } + } else if (accountRequested) { + // user clicked a notification, show notification tab and switch user if necessary + showNotificationTab = true + } + } + window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own + setContentView(R.layout.activity_main) + composeButton.setOnClickListener { + val composeIntent = Intent(applicationContext, ComposeActivity::class.java) + startActivity(composeIntent) + } + setupDrawer(savedInstanceState) + + /* 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() + + setupTabs(showNotificationTab) + + val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin) + viewPager.setPageTransformer(MarginPageTransformer(pageMargin)) + + val uswSwipeForTabs = PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean("enableSwipeForTabs", true) + viewPager.isUserInputEnabled = uswSwipeForTabs + + tabLayout.addOnTabSelectedListener(object : OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + if (tab.position == notificationTabPosition) { + NotificationHelper.clearNotificationsForActiveAccount(this@MainActivity, accountManager) + } + } + + override fun onTabUnselected(tab: TabLayout.Tab) {} + + override fun onTabReselected(tab: TabLayout.Tab) { + val fragment = adapter?.getFragment(tab.position) + if (fragment is ReselectableFragment) { + (fragment as ReselectableFragment).onReselect() + } + } + }) + + // Setup push notifications + if (NotificationHelper.areNotificationsEnabled(this, accountManager)) { + NotificationHelper.enablePullNotifications() + } else { + NotificationHelper.disablePullNotifications() + } + 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) + } + } + + // Flush old media that was cached for sharing + deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) + } + + override fun onResume() { + super.onResume() + NotificationHelper.clearNotificationsForActiveAccount(this, accountManager) + } + + override fun onBackPressed() { + when { + mainDrawerLayout.isOpen -> { + mainDrawerLayout.close() + } + viewPager.currentItem != 0 -> { + viewPager.currentItem = 0 + } + else -> { + super.onBackPressed() + } + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_MENU -> { + if (mainDrawerLayout.isOpen) { + mainDrawerLayout.close() + } else { + 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) + + drawerToggle.syncState() + + if (intent != null) { + val statusUrl = intent.getStringExtra(STATUS_URL) + if (statusUrl != null) { + viewUrl(statusUrl, PostLookupFallbackBehavior.DISPLAY_ERROR) + } + } + } + + override fun onDestroy() { + super.onDestroy() + EmojiCompat.get().unregisterInitCallback(emojiInitCallback) + } + + private fun forwardShare(intent: Intent) { + val composeIntent = Intent(this, ComposeActivity::class.java) + composeIntent.action = intent.action + composeIntent.type = intent.type + composeIntent.putExtras(intent) + composeIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(composeIntent) + finish() + } + + private fun setupDrawer(savedInstanceState: Bundle?) { + + drawerToggle = ActionBarDrawerToggle(this, mainDrawerLayout, mainToolbar, com.mikepenz.materialdrawer.R.string.material_drawer_open, com.mikepenz.materialdrawer.R.string.material_drawer_close) + + 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(mainDrawer) + dividerBelowHeader = false + closeDrawerOnProfileListClick = true + } + + header.accountHeaderBackground.setColorFilter(ContextCompat.getColor(this, R.color.headerBackgroundFilter)) + header.accountHeaderBackground.setBackgroundColor(ThemeUtils.getColor(this, R.attr.colorBackgroundAccent)) + val animateAvatars = PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean("animateGifAvatars", false) + + DrawerImageLoader.init(object : AbstractDrawerImageLoader() { + override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) { + if (animateAvatars) { + Glide.with(imageView.context) + .load(uri) + .placeholder(placeholder) + .into(imageView) + } else { + Glide.with(imageView.context) + .asBitmap() + .load(uri) + .placeholder(placeholder) + .into(imageView) + } + } + + override fun cancel(imageView: ImageView) { + Glide.with(imageView.context).clear(imageView) + } + + override fun placeholder(ctx: Context, tag: String?): Drawable { + if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) { + return ctx.getDrawable(R.drawable.avatar_default)!! + } + + return super.placeholder(ctx, tag) + } + }) + + 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_lists + iconicsIcon = GoogleMaterial.Icon.gmd_list + onClick = { + startActivityWithSlideInAnimation(ListsActivity.newIntent(context)) + } + }, + primaryDrawerItem { + nameRes = R.string.action_search + iconicsIcon = GoogleMaterial.Icon.gmd_search + onClick = { + startActivityWithSlideInAnimation(SearchActivity.getIntent(context)) + } + }, + primaryDrawerItem { + nameRes = R.string.action_access_saved_toot + iconRes = R.drawable.ic_notebook + onClick = { + val intent = Intent(context, SavedTootActivity::class.java) + startActivityWithSlideInAnimation(intent) + } + }, + primaryDrawerItem { + nameRes = R.string.action_access_scheduled_toot + iconRes = R.drawable.ic_access_time + onClick = { + startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context)) + } + }, + 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 + } + ) + setSavedInstance(savedInstanceState) + } + + if (BuildConfig.DEBUG) { + mainDrawer.addItems( + secondaryDrawerItem { + nameText = "debug" + isEnabled = false + textColor = ColorStateList.valueOf(Color.GREEN) + } + ) + } + EmojiCompat.get().registerInitCallback(emojiInitCallback) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + drawerToggle.onConfigurationChanged(newConfig) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (drawerToggle.onOptionsItemSelected(item)) { + return true + } + return super.onOptionsItemSelected(item) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(mainDrawer.saveInstanceState(outState)) + } + + private fun setupTabs(selectNotificationTab: Boolean) { + val tabs = accountManager.activeAccount!!.tabPreferences + adapter = MainPagerAdapter(tabs, this) + viewPager.adapter = adapter + TabLayoutMediator(tabLayout, viewPager, TabConfigurationStrategy { _: TabLayout.Tab?, _: Int -> }).attach() + tabLayout.removeAllTabs() + for (i in tabs.indices) { + val tab = tabLayout.newTab() + .setIcon(tabs[i].icon) + if (tabs[i].id == LIST) { + tab.contentDescription = tabs[i].arguments[1] + } else { + tab.setContentDescription(tabs[i].text) + } + tabLayout.addTab(tab) + if (tabs[i].id == NOTIFICATIONS) { + notificationTabPosition = i + if (selectNotificationTab) { + tab.select() + } + } + } + } + + 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, true)) + return false + } + //change Account + changeAccount(profile.identifier, null) + return false + } + + private fun changeAccount(newSelectedId: Long, forward: Intent?) { + cacheUpdater.stop() + SFragment.flushFilters() + accountManager.setActiveAccount(newSelectedId) + 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.yes) { _: DialogInterface?, _: Int -> + NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity) + cacheUpdater.clearForUser(activeAccount.id) + conversationRepository.deleteCacheForAccount(activeAccount.id) + removeShortcut(this, activeAccount) + val newAccount = accountManager.logActiveAccountOut() + if (!NotificationHelper.areNotificationsEnabled(this@MainActivity, accountManager)) { + NotificationHelper.disablePullNotifications() + } + val intent: Intent + intent = if (newAccount == null) { + LoginActivity.getIntent(this@MainActivity, false) + } else { + Intent(this@MainActivity, MainActivity::class.java) + } + startActivity(intent) + finishWithoutSlideOutAnimation() + } + .setNegativeButton(android.R.string.no, 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.with(this) + .asBitmap() + .load(me.header) + .into(header.accountHeaderBackground) + + accountManager.updateActiveAccount(me) + NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this) + + // Show follow requests in the menu, if this is a locked account. + if (me.locked && mainDrawer.getDrawerItem(DRAWER_ITEM_FOLLOW_REQUESTS) == null) { + val followRequestsItem = primaryDrawerItem { + identifier = DRAWER_ITEM_FOLLOW_REQUESTS + nameRes = R.string.action_view_follow_requests + iconicsIcon = GoogleMaterial.Icon.gmd_person_add + onClick = { + val intent = Intent(this@MainActivity, AccountListActivity::class.java) + intent.putExtra("type", AccountListActivity.Type.FOLLOW_REQUESTS) + startActivityWithSlideInAnimation(intent) + } + } + mainDrawer.addItemAtPosition(4, followRequestsItem) + } else if (!me.locked) { + mainDrawer.removeItems(DRAWER_ITEM_FOLLOW_REQUESTS) + } + updateProfiles() + updateShortcut(this, accountManager.activeAccount!!) + } + + private fun updateProfiles() { + val profiles: MutableList = accountManager.getAllAccountsOrderedByActive().map { acc -> + val emojifiedName = EmojiCompat.get().process(CustomEmojiHelper.emojifyString(acc.displayName, acc.emojis, header)) + + 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(): FloatingActionButton? = 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_FOLLOW_REQUESTS: Long = 10 + const val STATUS_URL = "statusUrl" + } +} + +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() + true + } + } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/PreferencesActivity.kt index 1af0f478..4d117d71 100644 --- a/app/src/main/java/com/keylesspalace/tusky/PreferencesActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/PreferencesActivity.kt @@ -129,7 +129,8 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference } "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", - "useBlurhash", "showCardsInTimelines", "confirmReblogs", "hideMutedUsers" -> { + "useBlurhash", "showCardsInTimelines", "confirmReblogs", "hideMutedUsers", + "enableSwipeForTabs" -> { restartActivitiesOnExit = true } "language" -> { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80f2edf3..899b0d99 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -535,6 +535,7 @@ Failed to search Show Notifications filter + Enable swipe gesture to switch between tabs Poll diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 6e75a611..1ea25247 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -90,6 +90,12 @@ android:title="@string/pref_title_hide_muted_users" app:singleLineTitle="false" /> + +