package org.pixeldroid.app import android.Manifest import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.ImageView import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.GravityCompat import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.paging.ExperimentalPagingApi import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import com.bumptech.glide.Glide import com.bumptech.glide.request.RequestOptions import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.color.DynamicColors import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.materialdrawer.iconics.iconicsIcon import com.mikepenz.materialdrawer.model.PrimaryDrawerItem import com.mikepenz.materialdrawer.model.ProfileDrawerItem import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem 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.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.widget.AccountHeaderView import kotlinx.coroutines.launch import org.json.JSONArray import org.json.JSONObject import org.ligi.tracedroid.sending.sendTraceDroidStackTracesIfExist import org.pixeldroid.app.databinding.ActivityMainBinding import org.pixeldroid.app.postCreation.camera.CameraFragment import org.pixeldroid.app.posts.NestedScrollableHost import org.pixeldroid.app.posts.feeds.cachedFeeds.CachedFeedFragment import org.pixeldroid.app.posts.feeds.cachedFeeds.notifications.NotificationsFragment import org.pixeldroid.app.posts.feeds.cachedFeeds.postFeeds.PostFeedFragment import org.pixeldroid.app.profile.ProfileActivity import org.pixeldroid.app.searchDiscover.SearchDiscoverFragment import org.pixeldroid.app.settings.SettingsActivity import org.pixeldroid.app.utils.BaseActivity import org.pixeldroid.app.utils.api.objects.Notification import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity import org.pixeldroid.app.utils.db.updateUserInfoDb import org.pixeldroid.app.utils.hasInternet import org.pixeldroid.app.utils.loadDefaultMenuTabs import org.pixeldroid.app.utils.loadJsonMenuTabs import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.INSTANCE_NOTIFICATION_TAG import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.SHOW_NOTIFICATION_TAG import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.USER_NOTIFICATION_TAG import org.pixeldroid.app.utils.notificationsWorker.enablePullNotifications import org.pixeldroid.app.utils.notificationsWorker.removeNotificationChannelsFromAccount import org.pixeldroid.app.utils.toList import java.time.Instant class MainActivity : BaseActivity() { private lateinit var header: AccountHeaderView private var user: UserDatabaseEntity? = null private val model: MainActivityViewModel by viewModels() companion object { const val ADD_ACCOUNT_IDENTIFIER: Long = -13 } private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen().setOnExitAnimationListener { it.remove() } // Workaround for dynamic colors not applying due to splash screen? DynamicColors.applyToActivityIfAvailable(this) super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) //get the currently active user user = db.userDao().getActiveUser() if (notificationFromOtherUser()) return //Check if we have logged in and gotten an access token if (user == null) { finish() launchActivity(LoginActivity(), firstTime = true) } else { sendTraceDroidStackTracesIfExist("contact@pixeldroid.org", this) setupDrawer() setupTabs() val showNotification: Boolean = intent.getBooleanExtra(SHOW_NOTIFICATION_TAG, false) if(showNotification){ binding.viewPager.currentItem = 3 } if (ActivityCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED ) enablePullNotifications(this) else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } } private val notificationPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted: Boolean -> if (isGranted) enablePullNotifications(this) } // Checks if the activity was launched from a notification from another account than the // current active one, and if so switches to that account private fun notificationFromOtherUser(): Boolean { val userOfNotification: String? = intent.extras?.getString(USER_NOTIFICATION_TAG) val instanceOfNotification: String? = intent.extras?.getString(INSTANCE_NOTIFICATION_TAG) if (userOfNotification != null && instanceOfNotification != null && (userOfNotification != user?.user_id || instanceOfNotification != user?.instance_uri) ) { switchUser(userOfNotification, instanceOfNotification) val newIntent = Intent(this, MainActivity::class.java) newIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK if (intent.getBooleanExtra(SHOW_NOTIFICATION_TAG, false)) { newIntent.putExtra(SHOW_NOTIFICATION_TAG, true) } finish() startActivity(newIntent) return true } return false } private fun setupDrawer() { binding.mainDrawerButton.setOnClickListener{ binding.drawerLayout.openDrawer(binding.drawer) } header = AccountHeaderView(this).apply { attachToSliderView(binding.drawer) headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP currentHiddenInList = true onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean -> clickProfile(profile, current) } addProfile(ProfileSettingDrawerItem().apply { identifier = ADD_ACCOUNT_IDENTIFIER nameRes = R.string.add_account_name descriptionRes = R.string.add_account_description iconicsIcon = GoogleMaterial.Icon.gmd_add }, 0) dividerBelowHeader = false closeDrawerOnProfileListClick = true } DrawerImageLoader.init(object : AbstractDrawerImageLoader() { override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) { Glide.with(this@MainActivity) .load(uri) .placeholder(placeholder) .circleCrop() .into(imageView) } override fun cancel(imageView: ImageView) { Glide.with(this@MainActivity).clear(imageView) } override fun placeholder(ctx: Context, tag: String?): Drawable { if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) { return ContextCompat.getDrawable(ctx, R.drawable.ic_default_user)!! } return super.placeholder(ctx, tag) } }) fillDrawerAccountInfo(user!!.user_id) //after setting with the values in the db, we make sure to update the database and apply //with the received one. This happens asynchronously. getUpdatedAccount() binding.drawer.itemAdapter.add( primaryDrawerItem { nameRes = R.string.menu_account iconicsIcon = GoogleMaterial.Icon.gmd_person }, primaryDrawerItem { nameRes = R.string.menu_settings iconicsIcon = GoogleMaterial.Icon.gmd_settings }, primaryDrawerItem { nameRes = R.string.logout iconicsIcon = GoogleMaterial.Icon.gmd_close }, ) binding.drawer.onDrawerItemClickListener = { v, drawerItem, position -> when (position){ 1 -> launchActivity(ProfileActivity()) 2 -> launchActivity(SettingsActivity()) 3 -> logOut() } false } // Closes the drawer if it is open, when we press the back button onBackPressedDispatcher.addCallback(this) { // Handle the back button event if(binding.drawerLayout.isDrawerOpen(GravityCompat.START)){ binding.drawerLayout.closeDrawer(GravityCompat.START) } else { this.isEnabled = false super.onBackPressedDispatcher.onBackPressed() } } } private fun logOut(){ finish() removeNotificationChannelsFromAccount(applicationContext, user) db.runInTransaction { db.userDao().deleteActiveUsers() val remainingUsers = db.userDao().getAll() if (remainingUsers.isEmpty()){ // No more users, start first-time login flow launchActivity(LoginActivity(), firstTime = true) } else { val newActive = remainingUsers.first() db.userDao().activateUser(newActive.user_id, newActive.instance_uri) apiHolder.setToCurrentUser() // Relaunch the app launchActivity(MainActivity(), firstTime = true) } } } private fun getUpdatedAccount() { if (hasInternet(applicationContext)) { lifecycleScope.launchWhenCreated { try { val api = apiHolder.api ?: apiHolder.setToCurrentUser() val account = api.verifyCredentials() updateUserInfoDb(db, account) //No need to update drawer account info here, the ViewModel listens to db updates } catch (exception: Exception) { Log.e("ACCOUNT UPDATE:", exception.toString()) } } } } //called when switching profiles, or when clicking on current profile private fun clickProfile(profile: IProfile, current: Boolean): Boolean { if(current){ launchActivity(ProfileActivity()) return false } //Clicked on add new account if(profile.identifier == ADD_ACCOUNT_IDENTIFIER){ launchActivity(LoginActivity()) return false } switchUser(profile.identifier.toString(), profile.tag as String) val intent = Intent(this, MainActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK finish() startActivity(intent) return false } private fun switchUser(userId: String, instance_uri: String) { db.runInTransaction{ db.userDao().deActivateActiveUsers() db.userDao().activateUser(userId, instance_uri) apiHolder.setToCurrentUser() } } private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem { return PrimaryDrawerItem() .apply { isSelectable = false isIconTinted = true } .apply(block) } private fun fillDrawerAccountInfo(account: String) { lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { model.users.collect { list -> val users = list.toMutableList() users.sortWith { l, r -> when { l.isActive && !r.isActive -> -1 r.isActive && !l.isActive -> 1 else -> 0 } } val profiles: MutableList = users.map { user -> ProfileDrawerItem().apply { isSelected = user.isActive nameText = user.display_name iconUrl = user.avatar_static isNameShown = true identifier = user.user_id.toLong() descriptionText = user.fullHandle tag = user.instance_uri } }.toMutableList() // reuse the already existing "add account" item header.profiles.orEmpty() .filter { it.identifier == ADD_ACCOUNT_IDENTIFIER } .take(1) .forEach { profiles.add(it) } header.clear() header.profiles = profiles header.setActiveProfile(account.toLong()) } } } } /** * Use reflection to make it a bit harder to swipe between tabs */ private fun ViewPager2.reduceDragSensitivity() { val recyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView") recyclerViewField.isAccessible = true val recyclerView = recyclerViewField.get(this) as RecyclerView val touchSlopField = RecyclerView::class.java.getDeclaredField("mTouchSlop") touchSlopField.isAccessible = true val touchSlop = touchSlopField.get(recyclerView) as Int touchSlopField.set(recyclerView, touchSlop*NestedScrollableHost.touchSlopModifier) } @OptIn(ExperimentalPagingApi::class) private fun setupTabs() { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) val tabsCheckedString = sharedPreferences.getString("tabsChecked", null) val pageIds = listOf(R.id.page_1, R.id.page_2, R.id.page_3, R.id.page_4, R.id.page_5) fun getDrawable(title: String): Drawable? { val resId = when (title) { getString(R.string.home_feed) -> R.drawable.selector_home_feed getString(R.string.search_discover_feed) -> R.drawable.ic_search_white_24dp getString(R.string.create_feed) -> R.drawable.selector_camera getString(R.string.notifications_feed) -> R.drawable.selector_notifications getString(R.string.public_feed) -> R.drawable.ic_filter_black_24dp else -> 0 } if (resId == 0) { return null } else { return AppCompatResources.getDrawable(applicationContext, resId) } } fun getFragment(title: String): (() -> Fragment)? { return when (title) { getString(R.string.home_feed) -> { { PostFeedFragment() .apply { arguments = Bundle().apply { putBoolean("home", true) } } } } getString(R.string.search_discover_feed) -> { { SearchDiscoverFragment() } } getString(R.string.create_feed) -> { { CameraFragment() } } getString(R.string.notifications_feed) -> { { NotificationsFragment() } } getString(R.string.public_feed) -> { { PostFeedFragment() .apply { arguments = Bundle().apply { putBoolean("home", false) } } } } else -> null } } val tabs = if (tabsCheckedString == null) { // Load default menu loadDefaultMenuTabs(applicationContext, binding.root) } else { // Get current menu visibility and order from settings val tabsChecked = loadJsonMenuTabs(tabsCheckedString).filter { it.second }.map { it.first } val bottomNavigationMenu: Menu = binding.tabs.menu bottomNavigationMenu.clear() tabsChecked.zip(pageIds).forEach { (tabTitle, pageId) -> with(bottomNavigationMenu.add(0, pageId, Menu.NONE, tabTitle)) { val tabIcon = getDrawable(tabTitle) if (tabIcon != null) { icon = tabIcon } } } tabsChecked } val tabArray: List<() -> Fragment> = tabs.map { getFragment(it)!! } binding.viewPager.reduceDragSensitivity() binding.viewPager.adapter = object : FragmentStateAdapter(this) { override fun createFragment(position: Int): Fragment { return tabArray[position]() } override fun getItemCount(): Int { return tabArray.size } } val notificationId = tabs.zip(pageIds).find { it.first == getString(R.string.notifications_feed) }?.second fun doAtPageId(pageId: Int): Int { if (notificationId != null && pageId == notificationId) { setNotificationBadge(false) } return pageId } binding.viewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() { override fun onPageSelected(position: Int) { val selected = when(position){ 0 -> doAtPageId(R.id.page_1) 1 -> doAtPageId(R.id.page_2) 2 -> doAtPageId(R.id.page_3) 3 -> doAtPageId(R.id.page_4) 4 -> doAtPageId(R.id.page_5) else -> null } if (selected != null) { binding.tabs.selectedItemId = selected } super.onPageSelected(position) } }) fun MenuItem.itemPos(): Int? { return when(itemId){ R.id.page_1 -> 0 R.id.page_2 -> 1 R.id.page_3 -> 2 R.id.page_4 -> 3 R.id.page_5 -> 4 else -> null } } binding.tabs.setOnItemSelectedListener {item -> item.itemPos()?.let { binding.viewPager.currentItem = it true } ?: false } binding.tabs.setOnItemReselectedListener { item -> item.itemPos()?.let { position -> val page = //No clue why this works but it does. F to pay respects supportFragmentManager.findFragmentByTag("f$position") (page as? CachedFeedFragment<*>)?.onTabReClicked() } } // Fetch one notification to show a badge if there are new notifications lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { user?.let { val lastNotification = db.notificationDao().latestNotification(it.user_id, it.instance_uri) try { val notification: List? = apiHolder.api?.notifications( min_id = lastNotification?.id, limit = "20" ) val filtered = notification?.filter { notification -> lastNotification == null || (notification.created_at ?: Instant.MIN) > (lastNotification.created_at ?: Instant.MIN) } val numberOfNewNotifications = if((filtered?.size ?: 20) >= 20) null else filtered?.size if(filtered?.isNotEmpty() == true ) setNotificationBadge(true, numberOfNewNotifications) } catch (exception: Exception) { return@repeatOnLifecycle } } } } } private fun setNotificationBadge(show: Boolean, count: Int? = null){ if(show){ val badge = binding.tabs.getOrCreateBadge(R.id.page_4) if (count != null) badge.number = count } else binding.tabs.removeBadge(R.id.page_4) } fun BottomNavigationView.uncheckAllItems() { menu.setGroupCheckable(0, true, false) for (i in 0 until menu.size()) { menu.getItem(i).isChecked = false } menu.setGroupCheckable(0, true, true) } /** * Launches the given activity and put it as the current one * @param firstTime to true means the task history will be reset (as if the app were * launched anew into this activity) */ private fun launchActivity(activity: AppCompatActivity, firstTime: Boolean = false) { val intent = Intent(this, activity::class.java) if(firstTime){ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } startActivity(intent) } }