184 lines
8.5 KiB
Kotlin
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
|
|
}
|
|
}
|