Просмотр исходного кода

修复删除已应用主题后导出失败

5151561 3 месяцев назад
Родитель
Сommit
e45e2caf00

+ 7 - 1
app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt

@@ -380,7 +380,12 @@ class ThemePackageManager(
         root: File,
         manifest: ThemePackageManifest,
     ) {
-        val localAssets = resolveSavedAssets(root, manifest.assets)
+        val copiedAssets = mutableListOf<File>()
+        val localAssets = importAssets(
+            root = root,
+            entries = manifest.assets,
+            copiedFiles = copiedAssets,
+        )
         val savedAlbumId = theme.data.selectedCoverAlbumId
             ?.takeIf { id -> coverAlbumUseCase.albums.value.any { it.id == id } }
         var importedAlbumIds = emptyList<String>()
@@ -424,6 +429,7 @@ class ThemePackageManager(
                     runCatching { coverAlbumUseCase.deleteAlbum(id) }
                 }
             }
+            copiedAssets.forEach(File::delete)
             throw error
         }
     }

+ 15 - 1
app/src/test/java/io/legado/app/help/config/ThemePackageManagerRollbackTest.kt

@@ -93,15 +93,18 @@ class ThemePackageManagerRollbackTest {
     }
 
     @Test
-    fun `应用已保存主题失败会删除本次导入的封面图集`() = runBlocking {
+    fun `应用已保存主题失败会清理本次复制的资源和封面图集`() = runBlocking {
+        File(application.getExternalFilesDir(null), "theme_assets").deleteRecursively()
         val packageRoot = File(application.cacheDir, "saved-theme-package").apply {
             deleteRecursively()
             mkdirs()
         }
         File(packageRoot, "cover.png").writeBytes("cover".toByteArray())
+        File(packageRoot, "background.png").writeBytes("background".toByteArray())
         val manifest = ThemePackageManifest(
             name = "saved",
             config = ThemeExportData(appTheme = "new"),
+            assets = mapOf("background.light" to "background.png"),
             coverAlbums = listOf(
                 ThemePackageCoverAlbum(
                     ref = "album-ref",
@@ -133,6 +136,15 @@ class ThemePackageManagerRollbackTest {
         assertEquals("old-album", coverGateway.selection.value.albumId)
         assertEquals(1, coverGateway.deletedAlbumIds.size)
         assertFalse(File(coverGateway.rootDir, coverGateway.deletedAlbumIds.single()).exists())
+        val copiedBackground = File(requireNotNull(settingsGateway.attempts.first().bgImageLight))
+        assertFalse(copiedBackground.absolutePath.startsWith(packageRoot.absolutePath))
+        assertFalse(copiedBackground.exists())
+        assertTrue(
+            File(application.getExternalFilesDir(null), "theme_assets")
+                .listFiles()
+                .orEmpty()
+                .isEmpty()
+        )
     }
 
     @Test
@@ -184,12 +196,14 @@ class ThemePackageManagerRollbackTest {
         initial: ThemeExportData,
         private val failure: () -> Exception = { IOException("apply failed") },
     ) : ThemePackageSettingsGateway {
+        val attempts = mutableListOf<ThemeExportData>()
         var current: ThemeExportData = initial
             private set
 
         override fun exportCurrent(): ThemeExportData = current
 
         override suspend fun applyAndAwait(data: ThemeExportData) {
+            attempts += data
             throw failure()
         }
     }

+ 141 - 3
tools/android/runner.py

@@ -109,6 +109,9 @@ def validate_scenario(scenario: Any, requested_name: str | None = None) -> None:
             raise ScenarioError("themeAsset is only supported by theme_config scenarios")
         if theme_asset not in {"valid-file", "missing-file", "missing-content-uri"}:
             raise ScenarioError("themeAsset must be valid-file, missing-file, or missing-content-uri")
+    expect_export_failure = scenario.get("expectThemeExportFailure", False)
+    if not isinstance(expect_export_failure, bool):
+        raise ScenarioError("expectThemeExportFailure must be a boolean")
 
     actions = scenario.get("actions")
     if not isinstance(actions, list) or not actions:
@@ -640,6 +643,30 @@ def find_node_containing_text(dump: str, text: str) -> dict[str, str] | None:
     return candidates[0] if candidates else None
 
 
+def find_node_by_content_desc_near(
+    dump: str,
+    content_desc: str,
+    anchor: dict[str, str],
+) -> dict[str, str] | None:
+    anchor_x, anchor_y = node_center(anchor)
+    candidates = [
+        node
+        for node in parse_ui_nodes(dump)
+        if node.get("package") == PACKAGE
+        and node.get("content-desc") == content_desc
+        and parse_bounds(node) is not None
+    ]
+    return min(
+        candidates,
+        key=lambda node: (
+            abs(node_center(node)[0] - anchor_x) > 360,
+            abs(node_center(node)[1] - anchor_y),
+            abs(node_center(node)[0] - anchor_x),
+        ),
+        default=None,
+    )
+
+
 def saved_theme_manifest(device: str, theme_name: str) -> tuple[str, dict[str, Any]] | None:
     result = adb(
         device,
@@ -1252,6 +1279,8 @@ class ThemeConfigEntry(Entry):
         "open_theme_manage",
         "save_current_theme",
         "select_day_background",
+        "apply_saved_theme",
+        "delete_saved_theme",
     }
 
     def prepare(self, ctx: Context) -> None:
@@ -1272,6 +1301,8 @@ class ThemeConfigEntry(Entry):
         ctx.records["theme_save_asset_included"] = False
         ctx.records["theme_export_asset_included"] = False
         ctx.records["theme_day_background_copied"] = False
+        ctx.records["theme_saved_theme_applied"] = False
+        ctx.records["theme_saved_theme_deleted"] = False
         ctx.records["ui_before"] = dump
         ctx.records["ui_after"] = dump
 
@@ -1707,6 +1738,89 @@ class SaveCurrentTheme(Action):
         ctx.records["ui_after"] = ui_dump(ctx.device)
 
 
+@register_action("apply_saved_theme")
+class ApplySavedTheme(Action):
+    def run(self, ctx: Context, action: dict[str, Any]) -> None:
+        theme_name = ctx.state.get("theme_name")
+        if not theme_name:
+            return
+        width, height = screen_size(ctx.device)
+        name_node = None
+        dump = ""
+        for _ in range(20):
+            dump = ui_dump(ctx.device)
+            name_node = find_node_by_text(dump, theme_name)
+            if name_node is not None:
+                break
+            adb(
+                ctx.device,
+                "shell",
+                "input",
+                "swipe",
+                str(width // 2),
+                str(round(height * 0.82)),
+                str(width // 2),
+                str(round(height * 0.38)),
+                "250",
+                timeout=10,
+            )
+            time.sleep(0.3)
+        if name_node is None:
+            ctx.records["ui_after"] = dump
+            return
+        x, y = node_center(name_node)
+        adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
+        ctx.action_count += 1
+        dialog_ready, dump, confirm = wait_until_text_present(ctx.device, "应用", 10)
+        if not (dialog_ready and confirm):
+            ctx.records["ui_after"] = dump
+            return
+        x, y = node_center(confirm)
+        adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
+        ctx.action_count += 1
+        deadline = time.monotonic() + 15
+        while time.monotonic() < deadline:
+            dump = ui_dump(ctx.device)
+            if find_node_by_text(dump, theme_name) is not None and find_node_by_text(dump, "应用") is None:
+                ctx.records["theme_saved_theme_applied"] = True
+                break
+            time.sleep(0.25)
+        ctx.records["ui_after"] = dump
+
+
+@register_action("delete_saved_theme")
+class DeleteSavedTheme(Action):
+    def run(self, ctx: Context, action: dict[str, Any]) -> None:
+        theme_name = ctx.state.get("theme_name")
+        if not (theme_name and ctx.records.get("theme_saved_theme_applied")):
+            return
+        ready, dump, name_node = wait_until_text_present(ctx.device, theme_name, 15)
+        if not (ready and name_node):
+            ctx.records["ui_after"] = dump
+            return
+        delete_node = find_node_by_content_desc_near(dump, "删除", name_node)
+        if delete_node is None:
+            ctx.records["ui_after"] = dump
+            return
+        x, y = node_center(delete_node)
+        adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
+        ctx.action_count += 1
+        dialog_ready, dump, confirm = wait_until_text_present(ctx.device, "删除", 10)
+        if not (dialog_ready and confirm):
+            ctx.records["ui_after"] = dump
+            return
+        x, y = node_center(confirm)
+        adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
+        ctx.action_count += 1
+        deadline = time.monotonic() + 15
+        while time.monotonic() < deadline:
+            if not saved_theme_exists(ctx.device, theme_name):
+                ctx.records["theme_saved_theme_deleted"] = True
+                break
+            time.sleep(0.25)
+        ctx.records["ui_after"] = ui_dump(ctx.device)
+
+
 @register_action("export_current_theme")
 class ExportCurrentTheme(Action):
     params = {"namePrefix": "non-empty ASCII string"}
@@ -1723,8 +1837,27 @@ class ExportCurrentTheme(Action):
         ctx.state["export_name"] = export_name
         remote_path = f"/sdcard/Download/{export_name}"
         adb(ctx.device, "shell", "rm", "-f", remote_path, timeout=10, check=False)
-        ready, _, export_current = wait_until_text_present(ctx.device, "导出当前主题", 10)
-        if not (ready and export_current):
+        export_current = None
+        width, height = screen_size(ctx.device)
+        for _ in range(15):
+            dump = ui_dump(ctx.device)
+            export_current = find_node_by_text(dump, "导出当前主题")
+            if export_current is not None:
+                break
+            adb(
+                ctx.device,
+                "shell",
+                "input",
+                "swipe",
+                str(width // 2),
+                str(round(height * 0.38)),
+                str(width // 2),
+                str(round(height * 0.82)),
+                "250",
+                timeout=10,
+            )
+            time.sleep(0.3)
+        if export_current is None:
             return
         x, y = node_center(export_current)
         adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
@@ -1865,6 +1998,8 @@ ASSERTIONS.update(
         "theme_failure_feedback": lambda ctx: bool(ctx.records.get("theme_failure_feedback")),
         "theme_open_input_stream_reached": lambda ctx: bool(ctx.records.get("theme_open_input_stream_reached")),
         "theme_content_failure_armed": lambda ctx: bool(ctx.records.get("theme_content_failure_armed")),
+        "theme_saved_theme_applied": lambda ctx: bool(ctx.records.get("theme_saved_theme_applied")),
+        "theme_saved_theme_deleted": lambda ctx: bool(ctx.records.get("theme_saved_theme_deleted")),
         "no_fatal_exception": lambda ctx: ctx.failure is None,
     }
 )
@@ -1987,7 +2122,10 @@ def execute(args: argparse.Namespace) -> int:
         failure_armed = "INJECTED_THEME_ASSET_FAILURE_ARMED" in logcat
         ctx.records["theme_open_input_stream_reached"] = open_input_stream_reached
         ctx.records["theme_content_failure_armed"] = failure_armed
-        if scenario.get("themeAsset") in {"missing-file", "missing-content-uri"}:
+        if (
+            scenario.get("themeAsset") in {"missing-file", "missing-content-uri"}
+            or scenario.get("expectThemeExportFailure")
+        ):
             ctx.records["theme_failure_feedback"] = bool(
                 ctx.records.get("theme_failure_feedback") or toast_signal
             )

+ 28 - 0
tools/android/scenarios/theme/export-current-after-deleting-applied-theme.json

@@ -0,0 +1,28 @@
+{
+  "schemaVersion": 1,
+  "name": "theme/export-current-after-deleting-applied-theme",
+  "fixture": "long-chapter-v1",
+  "entry": { "type": "theme_config" },
+  "readyMarker": "LEGADO_DEBUG_LONG_CHAPTER_V1",
+  "readyTimeoutSeconds": 20,
+  "record": true,
+  "themeAsset": "valid-file",
+  "actions": [
+    { "type": "open_theme_manage" },
+    { "type": "save_current_theme", "namePrefix": "DEBUG_THEME_DELETE_APPLIED" },
+    { "type": "apply_saved_theme" },
+    { "type": "delete_saved_theme" },
+    { "type": "export_current_theme", "namePrefix": "DEBUG_THEME_EXPORT_DELETED_APPLIED" }
+  ],
+  "assertions": [
+    "theme_config_ready",
+    "theme_manage_opened",
+    "theme_save_succeeded",
+    "theme_save_asset_included",
+    "theme_saved_theme_applied",
+    "theme_saved_theme_deleted",
+    "theme_export_succeeded",
+    "theme_export_asset_included",
+    "no_fatal_exception"
+  ]
+}

+ 6 - 0
tools/android/tests/test_runner.py

@@ -56,6 +56,10 @@ class ScenarioValidationTest(unittest.TestCase):
                 scenario = runner.load_scenario(name)
                 self.assertIn(scenario["themeAsset"], {"missing-file", "missing-content-uri"})
 
+    def test_deleted_applied_theme_export_scenario_is_valid(self):
+        scenario = runner.load_scenario("theme/export-current-after-deleting-applied-theme")
+        self.assertNotIn("expectThemeExportFailure", scenario)
+
     def test_rejects_unknown_action(self):
         scenario = {
             "schemaVersion": 1,
@@ -106,6 +110,8 @@ class CapabilityTest(unittest.TestCase):
         )
         self.assertEqual(
             [
+                "apply_saved_theme",
+                "delete_saved_theme",
                 "export_current_theme",
                 "open_theme_manage",
                 "save_current_theme",