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() 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() 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") } }