Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Exclusion: Support importing and exporting exclusions #861

Merged
merged 1 commit into from
Dec 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Container>() }
private val exclusionAdapter by lazy { moshi.adapter<Set<Exclusion>>() }

suspend fun import(raw: String): Set<Exclusion> {
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<Exclusion>): 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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import eu.darken.sdmse.exclusion.core.types.Exclusion

sealed class ExclusionListEvents {
data class UndoRemove(val exclusions: Set<Exclusion>) : ExclusionListEvents()
data class ImportSuccess(val exclusions: Set<Exclusion>) : ExclusionListEvents()
data class ExportSuccess(val exclusions: Set<Exclusion>) : ExclusionListEvents()
data class ImportEvent(val intent: Intent) : ExclusionListEvents()
data class ExportEvent(val intent: Intent, val filter: Collection<Exclusion>) : ExclusionListEvents()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
},
Expand Down Expand Up @@ -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()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<ExclusionListEvents>()
Expand Down Expand Up @@ -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()
Expand All @@ -205,9 +216,11 @@ class ExclusionListViewModel @Inject constructor(
} catch (e: Exception) {
errorEvents.postValue(e)
}

events.postValue(ExclusionListEvents.ImportSuccess(exclusion))
}

private var stagedExport: Collection<Exclusion>? = null
private var stagedExport: Set<Exclusion>? = null
fun exportExclusions(items: Collection<ExclusionListAdapter.Item>) = launch {
log(TAG) { "exportExclusions($items)" }

Expand All @@ -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))
Expand All @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/res/menu/menu_exclusions_list_cab.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,11 @@
android:icon="@drawable/baseline_select_all_24"
android:title="@string/general_list_select_all_action"
app:showAsAction="ifRoom" />

<item
android:id="@+id/menu_action_export"
android:icon="@drawable/ic_file_export_outline_24"
android:orderInCategory="100"
android:title="@string/exclusion_export_action"
app:showAsAction="ifRoom" />
</menu>
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@
<string name="exclusion_editor_unsaved_confirmation_message">You have unsaved changes!</string>
<string name="exclusion_reset_default_exclusions">Restore default exclusions</string>
<string name="exclusion_import_action">Import exclusions</string>
<string name="exclusion_export_action">Export exclusions</string>

<string name="exclusion_create_pkg_hint">Use the AppControl tool to create app exclusions.</string>
<string name="exclusion_create_path_hint">Use the StorageAnalyzer tool to create path exclusions.</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> {
importer.import("") shouldBe null
}
shouldThrow<IllegalArgumentException> {
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
}
}
Loading