From 3f96433cf90d351772be67a15834794ad25ebaef Mon Sep 17 00:00:00 2001 From: darken Date: Sat, 9 Dec 2023 23:17:49 +0100 Subject: [PATCH] Exclusion: Support importing and exporting exclusions --- .../sdmse/exclusion/core/ExclusionImporter.kt | 57 ++++++++ .../exclusion/ui/list/ExclusionListEvents.kt | 2 + .../ui/list/ExclusionListFragment.kt | 25 ++++ .../ui/list/ExclusionListViewModel.kt | 44 ++++-- .../res/menu/menu_exclusions_list_cab.xml | 7 + app/src/main/res/values/strings.xml | 1 + .../exclusion/core/ExclusionImporterTest.kt | 75 ++++++++++ .../core/types/SegmentExclusionTest.kt | 134 ++++++++++++++++++ 8 files changed, 331 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/eu/darken/sdmse/exclusion/core/ExclusionImporter.kt create mode 100644 app/src/test/java/eu/darken/sdmse/exclusion/core/ExclusionImporterTest.kt create mode 100644 app/src/test/java/eu/darken/sdmse/exclusion/core/types/SegmentExclusionTest.kt diff --git a/app/src/main/java/eu/darken/sdmse/exclusion/core/ExclusionImporter.kt b/app/src/main/java/eu/darken/sdmse/exclusion/core/ExclusionImporter.kt new file mode 100644 index 000000000..91cb37820 --- /dev/null +++ b/app/src/main/java/eu/darken/sdmse/exclusion/core/ExclusionImporter.kt @@ -0,0 +1,57 @@ +package eu.darken.sdmse.exclusion.core + +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import eu.darken.sdmse.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.sdmse.common.debug.logging.Logging.Priority.WARN +import eu.darken.sdmse.common.debug.logging.asLog +import eu.darken.sdmse.common.debug.logging.log +import eu.darken.sdmse.common.debug.logging.logTag +import eu.darken.sdmse.exclusion.core.types.Exclusion +import javax.inject.Inject + +class ExclusionImporter @Inject constructor( + private val moshi: Moshi, +) { + + private val containerAdapter by lazy { moshi.adapter() } + private val exclusionAdapter by lazy { moshi.adapter>() } + + suspend fun import(raw: String): Set { + if (raw.isEmpty()) throw IllegalArgumentException("Exclusion data was empty") + + try { + val container = containerAdapter.fromJson(raw) ?: throw IllegalArgumentException("Exclusion data was empty") + + if (container.version != 1) throw IllegalArgumentException("Unsupported version: ${container.version}") + + return exclusionAdapter.fromJson(container.exclusionRaw)!!.also { + log(TAG, VERBOSE) { "Imported ${it.size}\nINPUT: $raw\nOUTPUT: $it" } + } + } catch (e: Exception) { + log(TAG, WARN) { "Invalid data: ${e.asLog()}" } + throw IllegalArgumentException("Invalid exclusion data", e) + } + } + + suspend fun export(exclusions: Set): String { + val container = Container( + exclusionRaw = exclusionAdapter.toJson(exclusions) + ) + return containerAdapter.toJson(container).also { + log(TAG, VERBOSE) { "Exported ${exclusions.size}\nINPUT: $exclusions\nOUTPUT: $it" } + } + } + + + @JsonClass(generateAdapter = true) + data class Container( + val exclusionRaw: String, + val version: Int = 1, + ) + + companion object { + private val TAG = logTag("Exclusion", "Importer") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/sdmse/exclusion/ui/list/ExclusionListEvents.kt b/app/src/main/java/eu/darken/sdmse/exclusion/ui/list/ExclusionListEvents.kt index f60d64496..e3da4da41 100644 --- a/app/src/main/java/eu/darken/sdmse/exclusion/ui/list/ExclusionListEvents.kt +++ b/app/src/main/java/eu/darken/sdmse/exclusion/ui/list/ExclusionListEvents.kt @@ -5,6 +5,8 @@ import eu.darken.sdmse.exclusion.core.types.Exclusion sealed class ExclusionListEvents { data class UndoRemove(val exclusions: Set) : ExclusionListEvents() + data class ImportSuccess(val exclusions: Set) : ExclusionListEvents() + data class ExportSuccess(val exclusions: Set) : ExclusionListEvents() data class ImportEvent(val intent: Intent) : ExclusionListEvents() data class ExportEvent(val intent: Intent, val filter: Collection) : ExclusionListEvents() } diff --git a/app/src/main/java/eu/darken/sdmse/exclusion/ui/list/ExclusionListFragment.kt b/app/src/main/java/eu/darken/sdmse/exclusion/ui/list/ExclusionListFragment.kt index 1f05ab9ee..7e8f931fb 100644 --- a/app/src/main/java/eu/darken/sdmse/exclusion/ui/list/ExclusionListFragment.kt +++ b/app/src/main/java/eu/darken/sdmse/exclusion/ui/list/ExclusionListFragment.kt @@ -154,6 +154,12 @@ class ExclusionListFragment : Fragment3(R.layout.exclusion_list_fragment) { true } + R.id.menu_action_export -> { + vm.exportExclusions(selected) + tracker.clearSelection() + true + } + else -> false } }, @@ -192,6 +198,25 @@ class ExclusionListFragment : Fragment3(R.layout.exclusion_list_fragment) { is ExclusionListEvents.ExportEvent -> { exportPickerLauncher.launch(event.intent) } + + is ExclusionListEvents.ImportSuccess -> Snackbar + .make( + requireView(), + getQuantityString2( + R.plurals.exclusion_x_new_exclusions, + event.exclusions.size + ), + Snackbar.LENGTH_INDEFINITE + ) + .show() + + is ExclusionListEvents.ExportSuccess -> Snackbar + .make( + requireView(), + getString(eu.darken.sdmse.common.R.string.general_result_success_message), + Snackbar.LENGTH_INDEFINITE + ) + .show() } } diff --git a/app/src/main/java/eu/darken/sdmse/exclusion/ui/list/ExclusionListViewModel.kt b/app/src/main/java/eu/darken/sdmse/exclusion/ui/list/ExclusionListViewModel.kt index c97187e6d..79a9621f1 100644 --- a/app/src/main/java/eu/darken/sdmse/exclusion/ui/list/ExclusionListViewModel.kt +++ b/app/src/main/java/eu/darken/sdmse/exclusion/ui/list/ExclusionListViewModel.kt @@ -11,6 +11,7 @@ import eu.darken.sdmse.common.MimeTypes import eu.darken.sdmse.common.SingleLiveEvent import eu.darken.sdmse.common.WebpageTool import eu.darken.sdmse.common.coroutine.DispatcherProvider +import eu.darken.sdmse.common.debug.logging.Logging.Priority.INFO import eu.darken.sdmse.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.sdmse.common.debug.logging.Logging.Priority.WARN import eu.darken.sdmse.common.debug.logging.asLog @@ -23,6 +24,7 @@ import eu.darken.sdmse.common.uix.ViewModel3 import eu.darken.sdmse.common.upgrade.UpgradeRepo import eu.darken.sdmse.common.upgrade.isPro import eu.darken.sdmse.exclusion.core.DefaultExclusions +import eu.darken.sdmse.exclusion.core.ExclusionImporter import eu.darken.sdmse.exclusion.core.ExclusionManager import eu.darken.sdmse.exclusion.core.LegacyImporter import eu.darken.sdmse.exclusion.core.types.DefaultExclusion @@ -53,6 +55,7 @@ class ExclusionListViewModel @Inject constructor( private val webpageTool: WebpageTool, private val upgradeRepo: UpgradeRepo, private val legacyImporter: LegacyImporter, + private val exclusionImporter: ExclusionImporter, ) : ViewModel3(dispatcherProvider = dispatcherProvider) { val events = SingleLiveEvent() @@ -190,11 +193,19 @@ class ExclusionListViewModel @Inject constructor( } .mapNotNull { raw -> try { - legacyImporter.tryConvert(raw) + exclusionImporter.import(raw).also { + log(TAG, INFO) { "Imported ${it.size}: $it" } + } } catch (e: Exception) { - log(TAG, WARN) { "Import failed for $raw:\n${e.asLog()}" } - errorEvents.postValue(e) - null + log(TAG, WARN) { "This is not valid exclusion data, maybe legacy data?" } + try { + legacyImporter.tryConvert(raw).also { + log(TAG, INFO) { "Imported (legacy) ${it.size}: $it" } + } + } catch (e: Exception) { + log(TAG, WARN) { "Legacy import failed for $raw:\n${e.asLog()}" } + null + } } } .flatten() @@ -205,9 +216,11 @@ class ExclusionListViewModel @Inject constructor( } catch (e: Exception) { errorEvents.postValue(e) } + + events.postValue(ExclusionListEvents.ImportSuccess(exclusion)) } - private var stagedExport: Collection? = null + private var stagedExport: Set? = null fun exportExclusions(items: Collection) = launch { log(TAG) { "exportExclusions($items)" } @@ -218,7 +231,7 @@ class ExclusionListViewModel @Inject constructor( } val exclusion = items.map { it.exclusion } - stagedExport = exclusion + stagedExport = exclusion.toSet() val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) events.postValue(ExclusionListEvents.ExportEvent(intent, exclusion)) @@ -235,16 +248,19 @@ class ExclusionListViewModel @Inject constructor( val saveDir = DocumentFile.fromTreeUri(context, directoryUri) ?: throw IOException("Failed to access $directoryUri") - exportData.forEach { rawFilter -> -// val targetFile = saveDir.createFile(MimeTypes.Json.value, rawFilter.name) -// ?: throw IOException("Failed to create ${rawFilter.name} in $saveDir") -// -// context.contentResolver.openOutputStream(targetFile.uri)?.use { out -> -// out.write(rawFilter.payload.toByteArray()) -// } + val filename = "SD Maid 2/SE Exclusions ${System.currentTimeMillis()}" + + val targetFile = saveDir.createFile(MimeTypes.Json.value, filename) + ?: throw IOException("Failed to create ${filename} in $saveDir") -// log(TAG) { "Wrote ${rawFilter.name} to $targetFile" } + val rawContainer = exclusionImporter.export(exportData) + context.contentResolver.openOutputStream(targetFile.uri)?.use { out -> + out.write(rawContainer.toByteArray()) } + + log(TAG, VERBOSE) { "Wrote $rawContainer to ${targetFile.uri}" } + + events.postValue(ExclusionListEvents.ExportSuccess(exportData)) } companion object { diff --git a/app/src/main/res/menu/menu_exclusions_list_cab.xml b/app/src/main/res/menu/menu_exclusions_list_cab.xml index 22026495a..ea8c6c0af 100644 --- a/app/src/main/res/menu/menu_exclusions_list_cab.xml +++ b/app/src/main/res/menu/menu_exclusions_list_cab.xml @@ -13,4 +13,11 @@ android:icon="@drawable/baseline_select_all_24" android:title="@string/general_list_select_all_action" app:showAsAction="ifRoom" /> + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b02c6365c..cdf675a26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -533,6 +533,7 @@ You have unsaved changes! Restore default exclusions Import exclusions + Export exclusions Use the AppControl tool to create app exclusions. Use the StorageAnalyzer tool to create path exclusions. diff --git a/app/src/test/java/eu/darken/sdmse/exclusion/core/ExclusionImporterTest.kt b/app/src/test/java/eu/darken/sdmse/exclusion/core/ExclusionImporterTest.kt new file mode 100644 index 000000000..1f3b8b6be --- /dev/null +++ b/app/src/test/java/eu/darken/sdmse/exclusion/core/ExclusionImporterTest.kt @@ -0,0 +1,75 @@ +package eu.darken.sdmse.exclusion.core + +import eu.darken.sdmse.common.files.local.LocalPath +import eu.darken.sdmse.common.files.toSegs +import eu.darken.sdmse.common.pkgs.toPkgId +import eu.darken.sdmse.common.serialization.SerializationAppModule +import eu.darken.sdmse.exclusion.core.types.Exclusion +import eu.darken.sdmse.exclusion.core.types.PathExclusion +import eu.darken.sdmse.exclusion.core.types.PkgExclusion +import eu.darken.sdmse.exclusion.core.types.SegmentExclusion +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.json.toComparableJson + +class ExclusionImporterTest : BaseTest() { + private val moshi = SerializationAppModule().moshi() + + + fun create() = ExclusionImporter( + moshi = moshi + ) + + @Test + fun `invalid data returns null`() = runTest { + val importer = create() + + shouldThrow { + importer.import("") shouldBe null + } + shouldThrow { + importer.import("{\"exclusionRaw\": [], \"version\": 1}") shouldBe null + } + } + + @Test + fun `empty data returns empty`() = runTest { + val importer = create() + + importer.import("{\"exclusionRaw\": \"[]\", \"version\": 1}") shouldBe emptySet() + } + + @Test + fun `mixed exclusions`() = runTest { + val importer = create() + val og = setOf( + PkgExclusion( + pkgId = "test.pkg".toPkgId(), + tags = setOf(Exclusion.Tag.GENERAL), + ), + SegmentExclusion( + segments = "/test/path".toSegs(), + tags = setOf(Exclusion.Tag.APPCLEANER), + allowPartial = true, + ignoreCase = true, + ), + PathExclusion( + path = LocalPath.build("test", "path"), + tags = setOf(Exclusion.Tag.APPCLEANER) + ), + ) + val raw = importer.export(og) + + raw.toComparableJson() shouldBe """ + { + "exclusionRaw": "[{\"pkgId\":{\"name\":\"test.pkg\"},\"tags\":[\"GENERAL\"]},{\"segments\":[\"\",\"test\",\"path\"],\"allowPartial\":true,\"ignoreCase\":true,\"tags\":[\"APPCLEANER\"]},{\"path\":{\"file\":\"/test/path\",\"pathType\":\"LOCAL\"},\"tags\":[\"APPCLEANER\"]}]", + "version": 1.0 + } + """.toComparableJson() + + importer.import(raw) shouldBe og + } +} \ No newline at end of file diff --git a/app/src/test/java/eu/darken/sdmse/exclusion/core/types/SegmentExclusionTest.kt b/app/src/test/java/eu/darken/sdmse/exclusion/core/types/SegmentExclusionTest.kt new file mode 100644 index 000000000..c6df6969b --- /dev/null +++ b/app/src/test/java/eu/darken/sdmse/exclusion/core/types/SegmentExclusionTest.kt @@ -0,0 +1,134 @@ +package eu.darken.sdmse.exclusion.core.types + +import com.squareup.moshi.JsonDataException +import eu.darken.sdmse.common.files.core.local.tryMkFile +import eu.darken.sdmse.common.files.segs +import eu.darken.sdmse.common.serialization.SerializationAppModule +import eu.darken.sdmse.exclusion.core.types.Exclusion +import eu.darken.sdmse.exclusion.core.types.PathExclusion +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.json.toComparableJson +import java.io.File + +class SegmentExclusionTest : BaseTest() { + private val testFile = File(IO_TEST_BASEDIR, "testfile") + private val moshi = SerializationAppModule().moshi() + + @AfterEach + fun cleanup() { + testFile.delete() + } + + + @Test + fun `custom tags`() { + testFile.tryMkFile() + val original = SegmentExclusion( + segments = segs("test", "path"), + tags = setOf(Exclusion.Tag.DEDUPLICATOR, Exclusion.Tag.APPCLEANER), + allowPartial = true, + ignoreCase = true, + ) + + val adapter = moshi.adapter(SegmentExclusion::class.java) + + val json = adapter.toJson(original) + json.toComparableJson() shouldBe """ + { + "segments": [ + "test", + "path" + ], + "allowPartial": true, + "ignoreCase": true, + "tags": [ + "DEDUPLICATOR", + "APPCLEANER" + ] + } + """.toComparableJson() + + adapter.fromJson(json) shouldBe original + } + + @Test + fun `direct serialization`() { + testFile.tryMkFile() + val original = SegmentExclusion( + segments = segs("test", "path"), + tags = setOf(Exclusion.Tag.DEDUPLICATOR, Exclusion.Tag.APPCLEANER), + allowPartial = true, + ignoreCase = true, + ) + + val adapter = moshi.adapter(SegmentExclusion::class.java) + + val json = adapter.toJson(original) + json.toComparableJson() shouldBe """ + { + "segments": [ + "test", + "path" + ], + "allowPartial": true, + "ignoreCase": true, + "tags": [ + "DEDUPLICATOR", + "APPCLEANER" + ] + } + """.toComparableJson() + + adapter.fromJson(json) shouldBe original + } + + @Test + fun `polymorph serialization`() { + testFile.tryMkFile() + val original = SegmentExclusion( + segments = segs("test", "path"), + tags = setOf(Exclusion.Tag.DEDUPLICATOR, Exclusion.Tag.APPCLEANER), + allowPartial = true, + ignoreCase = true, + ) + + val adapter = moshi.adapter(Exclusion::class.java) + + val json = adapter.toJson(original) + json.toComparableJson() shouldBe """ + { + "segments": [ + "test", + "path" + ], + "allowPartial": true, + "ignoreCase": true, + "tags": [ + "DEDUPLICATOR", + "APPCLEANER" + ] + } + """.toComparableJson() + + adapter.fromJson(json) shouldBe original + } + + @Test + fun `force typing`() { + val original = SegmentExclusion( + segments = segs("test", "path"), + tags = setOf(Exclusion.Tag.DEDUPLICATOR, Exclusion.Tag.APPCLEANER), + allowPartial = true, + ignoreCase = true, + ) + + shouldThrow { + val json = moshi.adapter(SegmentExclusion::class.java).toJson(original) + moshi.adapter(PathExclusion::class.java).fromJson(json) + } + } +} \ No newline at end of file