package se.ajpanton.notificationlog import android.os.Bundle import android.net.Uri import android.content.ComponentName import android.content.Intent import android.provider.Settings import android.view.View import androidx.fragment.app.Fragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import se.ajpanton.notificationlog.databinding.FragmentSettingsBinding import se.ajpanton.notificationlog.settings.LogField import se.ajpanton.notificationlog.settings.LogViewSettingsStore 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 se.ajpanton.notificationlog.settings.StorageLimits import se.ajpanton.notificationlog.settings.StorageLimitsStore import java.util.zip.ZipOutputStream class SettingsFragment : Fragment(R.layout.fragment_settings) { private var binding: FragmentSettingsBinding? = null 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) 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 } bindStorageLimits() } override fun onDestroyView() { binding = null; super.onDestroyView() } override fun onResume() { super.onResume() binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access" } private fun isListenerEnabled(): Boolean = requireContext().getSystemService(android.app.NotificationManager::class.java) .isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java)) private fun bindStorageLimits() { val store = StorageLimitsStore(requireContext()) val limits = store.load() binding!!.logLimitMib.setText((limits.logBytes / StorageLimitsStore.MEBIBYTE).toString()) binding!!.imageLimitMib.setText((limits.imageBytes / StorageLimitsStore.MEBIBYTE).toString()) binding!!.saveStorageLimits.setOnClickListener { val logLimit = binding!!.logLimitMib.text.toString().toLongOrNull() val imageLimit = binding!!.imageLimitMib.text.toString().toLongOrNull() if (logLimit == null || imageLimit == null || logLimit !in 1..MAXIMUM_LIMIT_MIB || imageLimit !in 1..MAXIMUM_LIMIT_MIB ) { binding!!.logLimitMib.error = "Enter 1 to $MAXIMUM_LIMIT_MIB MiB" binding!!.imageLimitMib.error = "Enter 1 to $MAXIMUM_LIMIT_MIB MiB" return@setOnClickListener } store.save(StorageLimits(logLimit * StorageLimitsStore.MEBIBYTE, imageLimit * StorageLimitsStore.MEBIBYTE)) val appContext = requireContext().applicationContext Thread { EncryptedNotificationLogStore(appContext).enforceLimits() }.start() } } 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 logStore = EncryptedNotificationLogStore(context) val settings = LogViewSettingsStore(context).load() context.contentResolver.openOutputStream(uri)?.use { output -> when (format) { ExportFormat.CSV -> writeCsv(output.bufferedWriter(Charsets.UTF_8), logStore, settings, context) ExportFormat.FORMATTED -> writeFormatted(output.bufferedWriter(Charsets.UTF_8), logStore, settings, context) ExportFormat.HTML_ZIP -> writeHtmlZip(ZipOutputStream(output), logStore, settings, context) } } }.start() } private fun writeCsv( writer: java.io.BufferedWriter, logStore: EncryptedNotificationLogStore, settings: se.ajpanton.notificationlog.settings.LogViewSettings, context: android.content.Context, ) = writer.use { it.write(LogExporter.csvHeader(settings)) logStore.forEachNewest { entry -> it.newLine() it.write(LogExporter.csvRow(entry, settings, context)) } } private fun writeFormatted( writer: java.io.BufferedWriter, logStore: EncryptedNotificationLogStore, settings: se.ajpanton.notificationlog.settings.LogViewSettings, context: android.content.Context, ) = writer.use { val headers = LogExporter.headers(settings) val widths = headers.map { header -> header.length }.toMutableList() logStore.forEachNewest { entry -> LogExporter.values(entry, settings, context).forEachIndexed { index, value -> widths[index] = maxOf(widths[index], value.length) } } it.write(LogExporter.formattedRow(headers, widths)) logStore.forEachNewest { entry -> it.newLine() it.write(LogExporter.formattedRow(LogExporter.values(entry, settings, context), widths)) } } private fun writeHtmlZip( zip: ZipOutputStream, logStore: EncryptedNotificationLogStore, settings: se.ajpanton.notificationlog.settings.LogViewSettings, context: android.content.Context, ) = zip.use { zipOutput -> val imageStore = EncryptedImageStore(context) zipOutput.putNextEntry(java.util.zip.ZipEntry("notification-log.html")) zipOutput.write(LogExporter.htmlStart(settings).encodeToByteArray()) logStore.forEachNewest { entry -> val imagePath = entry.imageId ?.takeIf { LogField.CONTENTS in settings.visibleFields && imageStore.exists(it) } ?.let { imageId -> "images/$imageId.png" } zipOutput.write(LogExporter.htmlRow(entry, settings, imagePath, context).encodeToByteArray()) } zipOutput.write(LogExporter.htmlEnd().encodeToByteArray()) zipOutput.closeEntry() if (LogField.CONTENTS in settings.visibleFields) { logStore.forEachNewest { entry -> entry.imageId?.let { imageId -> imageStore.read(imageId)?.let { image -> zipOutput.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png")) zipOutput.write(image) zipOutput.closeEntry() } } } } } private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") } private companion object { const val MAXIMUM_LIMIT_MIB = 1024L * 1024L } }