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

184 lines
8.5 KiB
Kotlin

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
}
}