398 lines
17 KiB
Kotlin
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
|
|
}
|
|
}
|