Files
notifications-master/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt
T

398 lines
17 KiB
Kotlin

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
}
}