Files
notifications-master/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt
T

274 lines
14 KiB
Kotlin

package se.ajpanton.notificationlog
import android.os.Bundle
import android.content.ClipData
import android.net.Uri
import android.content.ComponentName
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.provider.Settings
import android.view.View
import android.widget.ArrayAdapter
import android.widget.CheckBox
import android.widget.LinearLayout
import android.widget.PopupWindow
import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.color.MaterialColors
import se.ajpanton.notificationlog.databinding.FragmentSettingsBinding
import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
import se.ajpanton.notificationlog.settings.TimestampZone
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.AppLockStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.capture.NotificationCaptureService
import se.ajpanton.notificationlog.export.LogExporter
import java.util.zip.ZipOutputStream
class SettingsFragment : Fragment(R.layout.fragment_settings) {
private var binding: FragmentSettingsBinding? = null
private var selectedCopyTargets = emptySet<LoggingType>()
private var pendingExport: ExportFormat? = null
private val createDocument = registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
uri?.let(::writeExport)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentSettingsBinding.bind(view)
val store = LogViewSettingsStore(requireContext())
var settings = store.load()
settings.order.forEach { field ->
binding!!.fieldToggles.addView(SwitchMaterial(requireContext()).apply {
text = when (field) {
LogField.TIMESTAMP -> "Timestamp"; LogField.APP_NAME -> "App name"
LogField.PACKAGE_NAME -> "Package name"; LogField.ACTION -> "What the app did"
LogField.CONTENTS -> "Contents of the notification"
}
isChecked = field in settings.visibleFields
tag = field
setOnLongClickListener {
startDragAndDrop(ClipData.newPlainText("field", field.name), View.DragShadowBuilder(this), this, 0)
true
}
setOnCheckedChangeListener { _, checked ->
settings = settings.copy(visibleFields = settings.visibleFields.toMutableSet().apply {
if (checked) add(field) else remove(field)
})
store.save(settings)
}
})
}
binding!!.fieldToggles.setOnDragListener { _, event ->
if (event.action != android.view.DragEvent.ACTION_DROP) return@setOnDragListener true
val source = event.localState as? View ?: return@setOnDragListener false
val container = binding!!.fieldToggles
val index = (0 until container.childCount).firstOrNull { event.y < container.getChildAt(it).bottom } ?: container.childCount
container.removeView(source); container.addView(source, index.coerceAtMost(container.childCount))
settings = settings.copy(order = (0 until container.childCount).map { container.getChildAt(it).tag as LogField })
store.save(settings)
true
}
val labels = listOf("Current local timezone", "UTC", "Local timezone when event happened")
binding!!.timezone.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, labels)
binding!!.timezone.setSelection(TimestampZone.entries.indexOf(settings.timestampZone))
binding!!.timezone.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
settings = settings.copy(timestampZone = TimestampZone.entries[position]); store.save(settings)
}
}
setupCopyControls()
setupEventMasterToggles()
binding!!.exportLogs.setOnClickListener { confirmExport() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
val lockStore = AppLockStore(requireContext())
binding!!.appLock.isChecked = lockStore.enabled
binding!!.appLock.setOnCheckedChangeListener { _, enabled -> lockStore.enabled = enabled }
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
override fun onResume() {
super.onResume()
refreshEventMasterToggles()
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access"
}
private fun setupEventMasterToggles() {
val container = binding!!.eventMasterToggles
LoggingType.entries.forEach { type ->
container.addView(SwitchMaterial(requireContext()).apply {
text = "${type.label()} logging"
tag = type
setOnCheckedChangeListener { _, enabled ->
LoggingRuleStore(requireContext()).save(type, LoggingRuleStore(requireContext()).ruleFor(type).copy(enabled = enabled))
}
})
}
refreshEventMasterToggles()
}
private fun refreshEventMasterToggles() {
val store = LoggingRuleStore(requireContext())
val container = binding?.eventMasterToggles ?: return
for (index in 0 until container.childCount) {
val toggle = container.getChildAt(index) as SwitchMaterial
toggle.setOnCheckedChangeListener(null)
toggle.isChecked = store.ruleFor(toggle.tag as LoggingType).enabled
toggle.setOnCheckedChangeListener { _, enabled ->
val type = toggle.tag as LoggingType
store.save(type, store.ruleFor(type).copy(enabled = enabled))
}
}
}
private fun setupCopyControls() {
val labels = listOf("Source") + LoggingType.entries.map { it.label() }
binding!!.copyFrom.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, labels)
binding!!.copyFrom.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedCopyTargets = emptySet()
binding!!.copyTo.isEnabled = position > 0
binding!!.copyTo.text = "Destination"
updateCopyControls()
}
}
binding!!.copyTo.setOnClickListener { showDestinationDropdown(LoggingType.entries[binding!!.copyFrom.selectedItemPosition - 1]) }
binding!!.copyEventSettings.setOnClickListener {
LoggingRuleStore(requireContext()).copy(LoggingType.entries[binding!!.copyFrom.selectedItemPosition - 1], selectedCopyTargets)
}
}
private fun showDestinationDropdown(source: LoggingType) {
val targets = LoggingType.entries.filterNot { it == source }
val popupContent = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(8), dp(8), dp(8), dp(8))
}
val popup = PopupWindow(popupContent, binding!!.copyTo.width.coerceAtLeast(dp(200)), LinearLayout.LayoutParams.WRAP_CONTENT, true).apply {
isOutsideTouchable = true
elevation = dp(8).toFloat()
setBackgroundDrawable(ColorDrawable(MaterialColors.getColor(requireContext(), com.google.android.material.R.attr.colorSurface, Color.WHITE)))
}
val boxes = mutableListOf<CheckBox>()
popupContent.addView(android.widget.Button(requireContext()).apply {
text = "All"
setOnClickListener {
val selectAll = selectedCopyTargets.size != targets.size
selectedCopyTargets = if (selectAll) targets.toSet() else emptySet()
boxes.forEachIndexed { index, box -> box.isChecked = targets[index] in selectedCopyTargets }
updateCopyControls()
}
})
popupContent.addView(View(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1).apply {
setMargins(0, dp(4), 0, dp(4))
}
setBackgroundColor(0x33000000)
})
targets.forEach { target ->
val box = CheckBox(requireContext()).apply {
text = target.label()
isChecked = target in selectedCopyTargets
setOnCheckedChangeListener { _, checked ->
selectedCopyTargets = selectedCopyTargets.toMutableSet().apply {
if (checked) add(target) else remove(target)
}
updateCopyControls()
}
}
boxes += box
popupContent.addView(box)
}
popup.showAsDropDown(binding!!.copyTo)
}
private fun updateCopyControls() {
val currentBinding = binding ?: return
val sourceChosen = currentBinding.copyFrom.selectedItemPosition > 0
currentBinding.copyTo.isEnabled = sourceChosen
currentBinding.copyTo.text = destinationLabel()
currentBinding.copyEventSettings.isEnabled = sourceChosen && selectedCopyTargets.isNotEmpty()
}
private fun destinationLabel(): String = when (selectedCopyTargets.size) {
0 -> "Destination"
1 -> selectedCopyTargets.first().label()
else -> "${selectedCopyTargets.sortedBy { it.ordinal }.first().label()}..."
}
private fun isListenerEnabled(): Boolean = requireContext().getSystemService(android.app.NotificationManager::class.java)
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
private fun confirmClearLogs() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Clear logs?")
.setMessage("This permanently removes every stored notification log and copied image.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Clear") { _, _ -> EncryptedNotificationLogStore(requireContext()).clear() }
.show()
}
private fun chooseExportFormat() {
MaterialAlertDialogBuilder(requireContext())
.setItems(arrayOf("CSV", "Formatted text", "HTML ZIP")) { _, which ->
pendingExport = ExportFormat.entries[which]
createDocument.launch("notification-log.${pendingExport!!.extension}")
}
.show()
}
private fun confirmExport() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Export unencrypted logs?")
.setMessage("The exported file will not be encrypted. Anyone with access to its destination can read the selected log fields.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Continue") { _, _ -> chooseExportFormat() }
.show()
}
private fun writeExport(uri: Uri) {
val format = pendingExport ?: return
val context = requireContext().applicationContext
Thread {
val entries = EncryptedNotificationLogStore(context).readAll()
val settings = LogViewSettingsStore(context).load()
context.contentResolver.openOutputStream(uri)?.use { output ->
when (format) {
ExportFormat.CSV -> output.write(LogExporter.csv(entries, settings).encodeToByteArray())
ExportFormat.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray())
ExportFormat.HTML_ZIP -> ZipOutputStream(output).use { zip ->
zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html"))
val imageStore = EncryptedImageStore(context)
val imageEntries = entries.filter { it.imageId != null && LogField.CONTENTS in settings.visibleFields }
val exportedImages = imageEntries.mapNotNull { entry ->
imageStore.read(entry.imageId!!)?.let { entry.imageId to it }
}.toMap()
zip.write(LogExporter.html(entries, settings) { entry ->
entry.imageId?.takeIf(exportedImages::containsKey)?.let { "images/$it.png" }
}.encodeToByteArray())
zip.closeEntry()
exportedImages.forEach { (imageId, image) ->
zip.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png"))
zip.write(image)
zip.closeEntry()
}
}
}
}
}.start()
}
private fun LoggingType.label() = name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase)
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") }
}