diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/AppVisibilityFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/AppVisibilityFragment.kt index 6b4bd38..0b71c8d 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/AppVisibilityFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/AppVisibilityFragment.kt @@ -1,19 +1,23 @@ package se.ajpanton.notificationsmaster +import android.graphics.drawable.Drawable import android.os.Bundle +import android.view.LayoutInflater +import android.view.MotionEvent import android.view.View import android.view.ViewGroup -import android.widget.Button import android.widget.EditText -import android.widget.LinearLayout +import android.widget.ImageView import android.widget.TextView import androidx.fragment.app.Fragment +import androidx.fragment.app.commit import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.switchmaterial.SwitchMaterial import se.ajpanton.notificationsmaster.databinding.FragmentAppVisibilityBinding +import se.ajpanton.notificationsmaster.databinding.ItemVisibilityExceptionBinding import se.ajpanton.notificationsmaster.visibility.AppVisibilityPolicy import se.ajpanton.notificationsmaster.visibility.NotificationSurface import se.ajpanton.notificationsmaster.visibility.VisibilityExceptionRule @@ -24,7 +28,8 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) { private var binding: FragmentAppVisibilityBinding? = null private lateinit var store: VisibilityPolicyStore private lateinit var packageName: String - private val adapter = ExceptionAdapter(::editException, ::deleteException) + private lateinit var adapter: ExceptionAdapter + private lateinit var touchHelper: ItemTouchHelper override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding = FragmentAppVisibilityBinding.bind(view) @@ -32,17 +37,29 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) { packageName = requireArguments().getString(ARG_PACKAGE)!! binding!!.appName.text = InstalledApps.name(requireContext(), packageName) binding!!.packageName.text = packageName + binding!!.returnToList.setOnClickListener(::returnToList) + + val icon = requireContext().packageManager.getApplicationIcon(packageName) + adapter = ExceptionAdapter(icon, ::editException, ::deleteException, ::saveExceptions) { + touchHelper.startDrag(it) + } binding!!.exceptions.layoutManager = LinearLayoutManager(requireContext()) binding!!.exceptions.adapter = adapter - ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) { - override fun onMove(list: RecyclerView, from: RecyclerView.ViewHolder, to: RecyclerView.ViewHolder): Boolean { + touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) { + override fun isLongPressDragEnabled() = false + + override fun onMove( + list: RecyclerView, + from: RecyclerView.ViewHolder, + to: RecyclerView.ViewHolder, + ): Boolean { adapter.move(from.bindingAdapterPosition, to.bindingAdapterPosition) - saveApp { copy(exceptions = adapter.rules()) } + saveExceptions(adapter.rules()) return true } override fun onSwiped(holder: RecyclerView.ViewHolder, direction: Int) = Unit - }).attachToRecyclerView(binding!!.exceptions) + }).also { it.attachToRecyclerView(binding!!.exceptions) } binding!!.addException.setOnClickListener { showExceptionDialog(null) } bind() } @@ -57,21 +74,27 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) { super.onDestroyView() } + private fun returnToList(@Suppress("UNUSED_PARAMETER") view: View) { + if (!parentFragmentManager.popBackStackImmediate()) { + parentFragmentManager.commit { replace(R.id.content_frame, NotificationVisibilityFragment()) } + } + } + private fun bind() { val policy = appPolicy() - bindSurface(binding!!.hideUnlocked, NotificationSurface.UNLOCKED_STATUSBAR, policy) - bindSurface(binding!!.hideLockscreen, NotificationSurface.LOCKSCREEN_COLLAPSED, policy) - bindSurface(binding!!.hideAod, NotificationSurface.AOD, policy) + bindSurface(binding!!.showAod, NotificationSurface.AOD, policy) + bindSurface(binding!!.showLockscreen, NotificationSurface.LOCKSCREEN_COLLAPSED, policy) + bindSurface(binding!!.showUnlocked, NotificationSurface.UNLOCKED_STATUSBAR, policy) adapter.submit(policy.exceptions) } private fun bindSurface(toggle: SwitchMaterial, surface: NotificationSurface, policy: AppVisibilityPolicy) { toggle.setOnCheckedChangeListener(null) - toggle.isChecked = surface in policy.blockedSurfaces - toggle.setOnCheckedChangeListener { _, hidden -> + toggle.isChecked = surface !in policy.blockedSurfaces + toggle.setOnCheckedChangeListener { _, visible -> saveApp { copy(blockedSurfaces = blockedSurfaces.toMutableSet().apply { - if (hidden) add(surface) else remove(surface) + if (visible) remove(surface) else add(surface) }) } } @@ -90,59 +113,46 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) { } } + private fun saveExceptions(rules: List) { + saveApp { copy(exceptions = rules) } + } + private fun editException(position: Int) = showExceptionDialog(position) private fun deleteException(position: Int) { - saveApp { copy(exceptions = exceptions.toMutableList().apply { removeAt(position) }) } - bind() + adapter.submit(adapter.rules().toMutableList().apply { removeAt(position) }) + saveExceptions(adapter.rules()) } private fun showExceptionDialog(position: Int?) { - val existing = position?.let { appPolicy().exceptions[it] } - val content = LinearLayout(requireContext()).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(24), 0, dp(24), 0) - } + val existing = position?.let { adapter.rules().getOrNull(it) } val pattern = EditText(requireContext()).apply { hint = "Regular expression" setText(existing?.pattern) maxLines = 4 - } - content.addView(pattern) - val toggles = listOf( - NotificationSurface.UNLOCKED_STATUSBAR to "Hide in unlocked status bar", - NotificationSurface.LOCKSCREEN_COLLAPSED to "Hide on collapsed lockscreen", - NotificationSurface.AOD to "Hide on always-on display", - ).associate { (surface, label) -> - surface to SwitchMaterial(requireContext()).apply { - text = label - isChecked = surface in existing?.blockedSurfaces.orEmpty() - content.addView(this) - } + setPadding(dp(24), 0, dp(24), 0) } val dialog = MaterialAlertDialogBuilder(requireContext()) .setTitle(if (existing == null) "Add text exception" else "Edit text exception") - .setView(content) + .setView(pattern) .setNegativeButton("Cancel", null) .setPositiveButton("Save", null) .create() dialog.setOnShowListener { dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val rule = runCatching { - VisibilityExceptionRule( - pattern.text.toString(), - toggles.filterValues { it.isChecked }.keys, - ).also { require(it.hasValidPattern()) } - }.getOrElse { + val rule = VisibilityExceptionRule( + pattern.text.toString(), + existing?.blockedSurfaces ?: appPolicy().blockedSurfaces, + ) + if (!rule.hasValidPattern()) { pattern.error = "Enter a valid, non-empty regular expression" return@setOnClickListener } - saveApp { - copy(exceptions = exceptions.toMutableList().apply { - if (position == null) add(rule) else set(position, rule) - }) + val rules = adapter.rules().toMutableList().apply { + if (position == null) add(rule) else set(position, rule) } - bind() + adapter.submit(rules) + saveExceptions(rules) dialog.dismiss() } } @@ -152,8 +162,11 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) { private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() private class ExceptionAdapter( + private val appIcon: Drawable, private val edit: (Int) -> Unit, private val delete: (Int) -> Unit, + private val changed: (List) -> Unit, + private val startDrag: (ExceptionHolder) -> Unit, ) : RecyclerView.Adapter() { private val items = mutableListOf() @@ -170,74 +183,72 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) { fun rules() = items.toList() - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ExceptionHolder { - val row = LinearLayout(parent.context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = android.view.Gravity.CENTER_VERTICAL - setPadding(0, dp(parent, 6), 0, dp(parent, 6)) - isClickable = true - isFocusable = true - background = context.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground)) - .let { values -> values.getDrawable(0).also { values.recycle() } } - } - val handle = TextView(parent.context).apply { - text = "↕" - textSize = 22f - gravity = android.view.Gravity.CENTER - contentDescription = "Drag to reorder" - layoutParams = LinearLayout.LayoutParams(dp(parent, 40), dp(parent, 48)) - } - val textColumn = LinearLayout(parent.context).apply { - orientation = LinearLayout.VERTICAL - layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - } - val pattern = TextView(parent.context).apply { textSize = 15f } - val summary = TextView(parent.context).apply { textSize = 12f } - textColumn.addView(pattern) - textColumn.addView(summary) - val remove = Button(parent.context).apply { - text = "Delete" - isAllCaps = false - } - row.addView(handle) - row.addView(textColumn) - row.addView(remove) - return ExceptionHolder(row, pattern, summary, remove) - } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ExceptionHolder( + ItemVisibilityExceptionBinding.inflate(LayoutInflater.from(parent.context), parent, false), + ) override fun onBindViewHolder(holder: ExceptionHolder, position: Int) { + val binding = holder.binding val rule = items[position] - holder.pattern.text = rule.pattern - holder.summary.text = surfaceSummary(rule.blockedSurfaces) - holder.itemView.setOnClickListener { - holder.bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION }?.let(edit) + binding.pattern.text = rule.pattern + bindSurface(binding.aodIcon, binding.aodCross, rule, NotificationSurface.AOD) + bindSurface( + binding.lockscreenIcon, + binding.lockscreenCross, + rule, + NotificationSurface.LOCKSCREEN_COLLAPSED, + ) + bindSurface( + binding.unlockedIcon, + binding.unlockedCross, + rule, + NotificationSurface.UNLOCKED_STATUSBAR, + ) + binding.aodCell.setOnClickListener { toggle(holder, NotificationSurface.AOD) } + binding.lockscreenCell.setOnClickListener { + toggle(holder, NotificationSurface.LOCKSCREEN_COLLAPSED) } - holder.remove.setOnClickListener { - holder.bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION }?.let(delete) + binding.unlockedCell.setOnClickListener { + toggle(holder, NotificationSurface.UNLOCKED_STATUSBAR) + } + binding.edit.setOnClickListener { holder.positionOrNull()?.let(edit) } + binding.delete.setOnClickListener { holder.positionOrNull()?.let(delete) } + binding.dragHandle.setOnTouchListener { _, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) startDrag(holder) + false } } override fun getItemCount() = items.size - private fun surfaceSummary(surfaces: Set): String { - val names = listOf( - NotificationSurface.UNLOCKED_STATUSBAR to "status bar", - NotificationSurface.LOCKSCREEN_COLLAPSED to "lockscreen", - NotificationSurface.AOD to "AOD", - ).filter { it.first in surfaces }.joinToString { it.second } - return if (names.isEmpty()) "Show on all surfaces" else "Hide: $names" + private fun toggle(holder: ExceptionHolder, surface: NotificationSurface) { + val position = holder.positionOrNull() ?: return + val current = items[position] + items[position] = current.copy(blockedSurfaces = current.blockedSurfaces.toMutableSet().apply { + if (surface in current.blockedSurfaces) remove(surface) else add(surface) + }) + notifyItemChanged(position) + changed(rules()) } - private fun dp(parent: ViewGroup, value: Int) = - (value * parent.resources.displayMetrics.density).toInt() + private fun bindSurface( + icon: ImageView, + cross: TextView, + rule: VisibilityExceptionRule, + surface: NotificationSurface, + ) { + icon.setImageDrawable(appIcon.constantState?.newDrawable()?.mutate() ?: appIcon) + val hidden = surface in rule.blockedSurfaces + icon.alpha = if (hidden) 0.25f else 1f + cross.visibility = if (hidden) View.VISIBLE else View.GONE + } + + private fun ExceptionHolder.positionOrNull() = + bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } } - private class ExceptionHolder( - view: View, - val pattern: TextView, - val summary: TextView, - val remove: Button, - ) : RecyclerView.ViewHolder(view) + private class ExceptionHolder(val binding: ItemVisibilityExceptionBinding) : + RecyclerView.ViewHolder(binding.root) companion object { private const val ARG_PACKAGE = "package" diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt index 4b3df88..c5e974e 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt @@ -78,7 +78,15 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte }) currentItemId = savedInstanceState?.getInt(STATE_CURRENT_ITEM_ID) ?: R.id.nav_view_logs - navigateTo(currentItemId) + if (savedInstanceState == null) { + navigateTo(currentItemId) + } else { + val page = Page.fromMenuItem(currentItemId) + refreshLogProtection(requestUnlock = hasWindowFocus()) + syncDrawerSelection(currentItemId) + title = getString(page.titleRes) + binding.root.post(::applyPageTitleVisibility) + } applyNavigationDrawerInsets() syncDrawerToggle() } @@ -156,6 +164,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte refreshLogProtection(requestUnlock = hasWindowFocus()) syncDrawerSelection(itemId) val page = Page.fromMenuItem(itemId) + supportFragmentManager.popBackStackImmediate( + null, + androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE, + ) supportFragmentManager.commit { replace( R.id.content_frame, diff --git a/app/src/main/res/drawable/ic_delete_cross.xml b/app/src/main/res/drawable/ic_delete_cross.xml new file mode 100644 index 0000000..4eb6910 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_cross.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_drag_dots.xml b/app/src/main/res/drawable/ic_drag_dots.xml new file mode 100644 index 0000000..527a1a0 --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_dots.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..9c8d284 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/fragment_app_visibility.xml b/app/src/main/res/layout/fragment_app_visibility.xml index 78d37b0..0a5a6aa 100644 --- a/app/src/main/res/layout/fragment_app_visibility.xml +++ b/app/src/main/res/layout/fragment_app_visibility.xml @@ -5,10 +5,18 @@ android:orientation="vertical" android:padding="@dimen/page_padding"> + + @@ -22,52 +30,45 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" - android:text="Hide by default" - android:textStyle="bold" /> + android:text="Default visibility" + android:textAppearance="?attr/textAppearanceHeadline6" /> - + android:text="Exceptions based on text contents" + android:textAppearance="?attr/textAppearanceHeadline6" /> - - -