274 lines
14 KiB
Kotlin
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") }
|
|
}
|