package se.ajpanton.notificationsmaster import android.os.Bundle import android.hardware.biometrics.BiometricPrompt import android.hardware.biometrics.BiometricManager.Authenticators import android.os.CancellationSignal import android.content.res.Configuration import android.view.Menu import android.view.MenuItem import android.view.View import android.view.WindowManager import android.widget.RelativeLayout import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity import androidx.core.graphics.Insets import androidx.core.view.GravityCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.forEach import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.commit import com.google.android.material.navigation.NavigationView import se.ajpanton.notificationsmaster.databinding.ActivityMainBinding import se.ajpanton.notificationsmaster.settings.AppLockStore import se.ajpanton.notificationsmaster.settings.LogAppFilterStore class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { private lateinit var binding: ActivityMainBinding private lateinit var drawerToggle: ActionBarDrawerToggle private var permanentSidebar = false private var needsUnlock = true private var unlockInProgress = false private lateinit var appLockStore: AppLockStore private var currentItemId = R.id.nav_view_logs private var drawerNavigationBasePaddingLeft: Int? = null private var permanentNavigationBasePaddingLeft: Int? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) appLockStore = AppLockStore(this) // Keep every app surface below the system bars rather than relying on edge-to-edge drawing. WindowCompat.setDecorFitsSystemWindows(window, true) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) val isNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES WindowInsetsControllerCompat(window, binding.root).isAppearanceLightNavigationBars = !isNightMode setSupportActionBar(binding.toolbar) setupNavigationView(binding.navViewDrawer) setupNavigationView(binding.navViewPermanent) drawerToggle = ActionBarDrawerToggle( this, binding.drawerLayout, binding.toolbar, R.string.navigation_open, R.string.navigation_close, ) binding.drawerLayout.addDrawerListener(drawerToggle) binding.toolbar.setNavigationOnClickListener { if (!permanentSidebar) { binding.drawerLayout.openDrawer(GravityCompat.START) } } onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { if (!permanentSidebar && binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { binding.drawerLayout.closeDrawer(GravityCompat.START) return } isEnabled = false onBackPressedDispatcher.onBackPressed() isEnabled = true } }) currentItemId = savedInstanceState?.getInt(STATE_CURRENT_ITEM_ID) ?: R.id.nav_view_logs navigateTo(currentItemId) applyNavigationDrawerInsets() syncDrawerToggle() } override fun onSaveInstanceState(outState: Bundle) { outState.putInt(STATE_CURRENT_ITEM_ID, currentItemId) super.onSaveInstanceState(outState) } override fun onResume() { super.onResume() refreshLogProtection(requestUnlock = true) } override fun onStop() { super.onStop() if (!isChangingConfigurations && isProtectedLogPage()) { needsUnlock = true showLockedOverlay(true) } } private fun requestUnlock() { if (!isProtectedLogPage() || !needsUnlock || unlockInProgress) return unlockInProgress = true BiometricPrompt.Builder(this) .setTitle("Unlock notification logs") .setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL) .build() .authenticate(CancellationSignal(), mainExecutor, object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) { needsUnlock = false unlockInProgress = false refreshLogProtection() } override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) { unlockInProgress = false if (isProtectedLogPage()) navigateTo(R.id.nav_alerts) } }) } private fun showLockedOverlay(visible: Boolean) { binding.lockOverlay.visibility = if (visible) View.VISIBLE else View.GONE } /** Called immediately when the Settings switch changes, before a task snapshot can be captured. */ fun setAppLockEnabled(enabled: Boolean) { appLockStore.enabled = enabled needsUnlock = enabled refreshLogProtection(requestUnlock = true) } private fun isProtectedLogPage() = appLockStore.enabled && currentItemId == R.id.nav_view_logs private fun refreshLogProtection(requestUnlock: Boolean = false) { val protectedLogs = isProtectedLogPage() setRecentsScreenshotEnabled(!protectedLogs) if (protectedLogs) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) showLockedOverlay(protectedLogs && needsUnlock) if (protectedLogs && needsUnlock && requestUnlock) binding.root.post(::requestUnlock) } override fun onNavigationItemSelected(item: MenuItem): Boolean { navigateTo(item.itemId) if (!permanentSidebar) { binding.drawerLayout.closeDrawer(GravityCompat.START) } return true } private fun navigateTo(itemId: Int) { currentItemId = itemId refreshLogProtection(requestUnlock = hasWindowFocus()) syncDrawerSelection(itemId) val page = Page.fromMenuItem(itemId) supportFragmentManager.commit { replace( R.id.content_frame, when { page == Page.VIEW_LOGS -> ViewLogsFragment() page == Page.SETTINGS -> SettingsFragment() page == Page.ALERTS -> AlertsFragment() page == Page.ALERT_STATUS -> AlertStatusFragment() page == Page.PROFILES -> ProfilesFragment() page == Page.RULES -> RulesFragment() page == Page.APPS -> AppsFragment() page == Page.NOTIFICATION_VISIBILITY -> NotificationVisibilityFragment() page == Page.CARDS_LAYOUT -> CardsLayoutFragment() page == Page.DEBUG_NOTIFICATIONS -> DebugNotificationsFragment() page == Page.MISCELLANEOUS -> MiscellaneousFragment() page == Page.LOG_DISPLAY -> LogDisplayFragment() page == Page.FILTER_LOGGING -> FilterLoggingFragment() page == Page.FILTER_APPS -> FilterAppsFragment.newInstance() else -> PageFragment.newInstance(page.titleRes) }, ) runOnCommit(::applyPageTitleVisibility) } title = getString(page.titleRes) invalidateOptionsMenu() } private fun setupNavigationView(navigationView: NavigationView) { navigationView.setNavigationItemSelectedListener(this) navigationView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> updateNavigationHeaderHeight(navigationView) } } private fun syncDrawerSelection(itemId: Int) { syncNavigationSelection(binding.navViewDrawer, itemId) syncNavigationSelection(binding.navViewPermanent, itemId) } private fun syncNavigationSelection(navigationView: NavigationView, itemId: Int) { val menu = navigationView.menu ?: return menu.forEach { item -> item.isCheckable = true item.isChecked = item.itemId == itemId } navigationView.invalidate() } private fun applyNavigationDrawerInsets() { binding.drawerLayout.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> applyNavigationDrawerMarginsFromRootInsets() } binding.drawerLayout.post(::applyNavigationDrawerMarginsFromRootInsets) } private fun applyNavigationDrawerMarginsFromRootInsets() { val rootInsets = ViewCompat.getRootWindowInsets(binding.drawerLayout) val safeInsets = rootInsets?.getInsetsIgnoringVisibility( WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), ) ?: Insets.NONE applyNavigationDrawerSafeArea(safeInsets.left, safeInsets.right) } private fun applyNavigationDrawerSafeArea(leftInset: Int, rightInset: Int) { val safeLeft = maxOf(0, leftInset, binding.drawerLayout.paddingLeft) val safeRight = maxOf(0, rightInset, binding.drawerLayout.paddingRight) binding.drawerLayout.setDrawerSafeInsets(safeLeft, safeRight) drawerNavigationBasePaddingLeft = applyNavigationLeftPadding( binding.navViewDrawer, drawerNavigationBasePaddingLeft, safeLeft, ) permanentNavigationBasePaddingLeft = applyNavigationLeftPadding( binding.navViewPermanent, permanentNavigationBasePaddingLeft, 0, ) updatePermanentSidebarMode() } private fun applyNavigationLeftPadding( navigationView: NavigationView, basePaddingLeft: Int?, safeLeft: Int, ): Int { val base = basePaddingLeft ?: navigationView.paddingLeft val leftPadding = base + safeLeft if (navigationView.paddingLeft != leftPadding || navigationView.translationX != 0f) { navigationView.translationX = 0f navigationView.setPadding( leftPadding, navigationView.paddingTop, navigationView.paddingRight, navigationView.paddingBottom, ) navigationView.requestLayout() } return base } private fun updatePermanentSidebarMode() { val rootWidth = binding.drawerLayout.width if (rootWidth <= 0) { return } val drawerWidth = resources.getDimensionPixelSize(R.dimen.navigation_drawer_width) val foldedPortraitWidth = resources.getDimensionPixelSize(R.dimen.folded_portrait_width) val shouldUsePermanentSidebar = rootWidth >= drawerWidth + (foldedPortraitWidth * PERMANENT_SIDEBAR_FOLDED_WIDTH_MULTIPLIER).toInt() if (permanentSidebar != shouldUsePermanentSidebar) { permanentSidebar = shouldUsePermanentSidebar drawerToggle.isDrawerIndicatorEnabled = !permanentSidebar binding.toolbar.visibility = if (permanentSidebar) View.GONE else View.VISIBLE if (permanentSidebar) { binding.navViewPermanent.visibility = View.VISIBLE binding.drawerLayout.closeDrawer(GravityCompat.START, false) } else { binding.drawerLayout.closeDrawer(GravityCompat.START, false) binding.navViewPermanent.visibility = View.GONE } syncDrawerToggle() } if (permanentSidebar) { binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.START) binding.navViewDrawer.visibility = View.GONE } else { binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.START) binding.navViewDrawer.visibility = View.VISIBLE } (binding.contentFrame.layoutParams as RelativeLayout.LayoutParams).also { params -> params.removeRule(if (permanentSidebar) RelativeLayout.BELOW else RelativeLayout.ALIGN_PARENT_TOP) params.addRule( if (permanentSidebar) RelativeLayout.ALIGN_PARENT_TOP else RelativeLayout.BELOW, if (permanentSidebar) RelativeLayout.TRUE else R.id.toolbar, ) binding.contentFrame.layoutParams = params } updateNavigationHeaderHeight(binding.navViewDrawer) updateNavigationHeaderHeight(binding.navViewPermanent) applyPageTitleVisibility() } private fun updateNavigationHeaderHeight(navigationView: NavigationView) { if (navigationView.headerCount == 0 || navigationView.height <= 0) { return } val menuRows = countVisibleMenuRows(navigationView.menu) val menuHeight = menuRows * resources.getDimensionPixelSize(R.dimen.navigation_menu_item_height) val desiredHeaderHeight = (navigationView.height - menuHeight).coerceIn( resources.getDimensionPixelSize(R.dimen.drawer_header_min_height), resources.getDimensionPixelSize(R.dimen.drawer_header_max_height), ) val header = navigationView.getHeaderView(0) ?: return if (header.layoutParams.height != desiredHeaderHeight) { header.layoutParams.height = desiredHeaderHeight header.requestLayout() } } private fun countVisibleMenuRows(menu: Menu): Int { var visible = 0 menu.forEach { if (it.isVisible) visible++ } return visible } private fun applyPageTitleVisibility() { (supportFragmentManager.findFragmentById(R.id.content_frame) as? PageFragment) ?.setPageTitleVisible(permanentSidebar) } private fun syncDrawerToggle() { drawerToggle.syncState() drawerToggle.drawerArrowDrawable.color = getColor(R.color.on_primary) } fun updateLogFilterAction() { if (currentItemId == R.id.nav_view_logs) invalidateOptionsMenu() } override fun onCreateOptionsMenu(menu: Menu): Boolean { return updateToolbarActions(menu) } override fun onPrepareOptionsMenu(menu: Menu): Boolean { menu.clear() return updateToolbarActions(menu) } private fun updateToolbarActions(menu: Menu): Boolean { if (currentItemId != R.id.nav_view_logs) return false val active = LogAppFilterStore(this).selectedPackages().isNotEmpty() menu.add(Menu.NONE, MENU_LOG_FILTER, Menu.NONE, "Filter logs by app").apply { setIcon(if (active) R.drawable.ic_log_filter_active else R.drawable.ic_log_filter) icon?.setTint(getColor(R.color.on_primary)) setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) } return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == MENU_LOG_FILTER) { (supportFragmentManager.findFragmentById(R.id.content_frame) as? ViewLogsFragment)?.showAppFilter() return true } return super.onOptionsItemSelected(item) } private enum class Page( val menuId: Int, val titleRes: Int, ) { VIEW_LOGS(R.id.nav_view_logs, R.string.page_view_logs), SETTINGS(R.id.nav_settings, R.string.page_settings), ALERTS(R.id.nav_alerts, R.string.page_alerts), ALERT_STATUS(R.id.nav_alert_status, R.string.page_alert_status), PROFILES(R.id.nav_profiles, R.string.page_profiles), RULES(R.id.nav_rules, R.string.page_rules), APPS(R.id.nav_apps, R.string.page_apps), NOTIFICATION_VISIBILITY(R.id.nav_notification_visibility, R.string.page_notification_visibility), CARDS_LAYOUT(R.id.nav_cards_layout, R.string.page_cards_layout), DEBUG_NOTIFICATIONS(R.id.nav_debug_notifications, R.string.page_debug_notifications), MISCELLANEOUS(R.id.nav_miscellaneous, R.string.page_miscellaneous), LOG_DISPLAY(R.id.nav_log_display, R.string.page_log_display), FILTER_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging), FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps), ; companion object { fun fromMenuItem(menuId: Int): Page = entries.firstOrNull { it.menuId == menuId } ?: VIEW_LOGS } } private companion object { const val STATE_CURRENT_ITEM_ID = "current_item_id" const val MENU_LOG_FILTER = 1 const val PERMANENT_SIDEBAR_FOLDED_WIDTH_MULTIPLIER = 1.1f } }