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

feat: 重构书源与订阅源编辑调试界面

HapeLee 3 месяцев назад
Родитель
Сommit
2b77459535
30 измененных файлов с 1190 добавлено и 1731 удалено
  1. 6 0
      app/src/main/java/io/legado/app/di/appModule.kt
  2. 98 33
      app/src/main/java/io/legado/app/model/Debug.kt
  3. 2 2
      app/src/main/java/io/legado/app/model/webBook/BookList.kt
  4. 26 226
      app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugActivity.kt
  5. 0 44
      app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugAdapter.kt
  6. 56 0
      app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugContract.kt
  7. 0 60
      app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugModel.kt
  8. 327 0
      app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugScreen.kt
  9. 150 0
      app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugViewModel.kt
  10. 15 130
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt
  11. 0 44
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugAdapter.kt
  12. 40 0
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugContract.kt
  13. 0 59
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt
  14. 194 0
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugScreen.kt
  15. 97 0
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugViewModel.kt
  16. 24 458
      app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt
  17. 0 132
      app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditAdapter.kt
  18. 36 0
      app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditContract.kt
  19. 45 0
      app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditScreen.kt
  20. 45 107
      app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditViewModel.kt
  21. 12 24
      app/src/main/java/io/legado/app/web/socket/BookSourceDebugWebSocket.kt
  22. 12 23
      app/src/main/java/io/legado/app/web/socket/RssSourceDebugWebSocket.kt
  23. 0 129
      app/src/main/res/layout/activity_rss_source_edit.xml
  24. 0 196
      app/src/main/res/layout/activity_source_debug.xml
  25. 0 43
      app/src/main/res/menu/book_source_debug.xml
  26. 0 17
      app/src/main/res/menu/rss_source_debug.xml
  27. 1 1
      app/src/main/res/values-zh-rCN/strings.xml
  28. 1 1
      app/src/main/res/values-zh-rHK/strings.xml
  29. 1 1
      app/src/main/res/values-zh-rTW/strings.xml
  30. 2 1
      app/src/main/res/values/strings.xml

+ 6 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -222,7 +222,10 @@ import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
 import io.legado.app.ui.book.search.SearchViewModel
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
 import io.legado.app.ui.book.source.edit.BookSourceEditViewModel
+import io.legado.app.ui.book.source.debug.BookSourceDebugViewModel
 import io.legado.app.ui.book.source.manage.BookSourceViewModel
+import io.legado.app.ui.rss.source.edit.RssSourceEditViewModel
+import io.legado.app.ui.rss.source.debug.RssSourceDebugViewModel
 import io.legado.app.ui.book.toc.TocViewModel
 import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
 import io.legado.app.ui.book.toc.rule.preview.TxtTocRulePreviewViewModel
@@ -451,6 +454,9 @@ val appModule = module {
     viewModelOf(::RssSourceViewModel)
     viewModelOf(::BookSourceViewModel)
     viewModelOf(::BookSourceEditViewModel)
+    viewModelOf(::BookSourceDebugViewModel)
+    viewModelOf(::RssSourceEditViewModel)
+    viewModelOf(::RssSourceDebugViewModel)
     viewModelOf(::RssSortViewModel)
     viewModelOf(::RssArticlesViewModel)
     viewModelOf(::ReadRssViewModel)

+ 98 - 33
app/src/main/java/io/legado/app/model/Debug.kt

@@ -14,11 +14,16 @@ import io.legado.app.utils.HtmlFormatter
 import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.stackTraceStr
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import java.util.concurrent.atomic.AtomicLong
 import java.text.SimpleDateFormat
 import java.util.*
 
 object Debug {
-    var callback: Callback? = null
+    private val nextSessionId = AtomicLong()
+    private var activeSession: Session? = null
     private var debugSource: String? = null
     private val tasks: CompositeCoroutine = CompositeCoroutine()
     val debugMessageMap = HashMap<String, String>()
@@ -42,17 +47,28 @@ object Debug {
             Log.d("sourceDebug", msg)
         }
         //调试信息始终要执行
-        callback?.let {
-            if ((debugSource != sourceUrl || !print)) return
-            var printMsg = msg
-            if (isHtml) {
-                printMsg = HtmlFormatter.format(msg)
-            }
+        val event = Event(
+            kind = when (state) {
+                -1 -> EventKind.Error
+                10 -> EventKind.SearchSource
+                20 -> EventKind.InfoSource
+                30 -> EventKind.TocSource
+                40 -> EventKind.ContentSource
+                1000 -> EventKind.Completed
+                else -> EventKind.Message
+            },
+            message = if (isHtml) HtmlFormatter.format(msg) else msg,
+            timestamp = System.currentTimeMillis(),
+            elapsedMillis = System.currentTimeMillis() - startTime,
+        )
+        if (debugSource == sourceUrl && print) {
+            var printMsg = event.message
             if (showTime) {
                 val time = debugTimeFormat.format(Date(System.currentTimeMillis() - startTime))
                 printMsg = "$time $printMsg"
             }
-            it.printLog(state, printMsg)
+            val structuredEvent = event.copy(message = printMsg)
+            activeSession?.emit(structuredEvent)
         }
         if (isChecking && sourceUrl != null && (msg).length < 30) {
             var printMsg = msg
@@ -71,18 +87,30 @@ object Debug {
 
     @Synchronized
     fun log(msg: String?) {
-        log(debugSource, msg ?: "", true)
+        log(debugSource, if (msg == null) "" else msg, true)
     }
 
-    fun cancelDebug(destroy: Boolean = false) {
+    @Synchronized
+    private fun replaceSession(sourceUrl: String): Session {
         tasks.clear()
+        activeSession?.close()
+        debugSource = sourceUrl
+        startTime = System.currentTimeMillis()
+        return Session(nextSessionId.incrementAndGet(), sourceUrl).also { activeSession = it }
+    }
 
-        if (destroy) {
-            debugSource = null
-            callback = null
-        }
+    @Synchronized
+    private fun cancel(session: Session) {
+        if (activeSession?.id != session.id) return
+        tasks.clear()
+        activeSession = null
+        debugSource = null
+        session.close()
     }
 
+    val hasActiveSession: Boolean
+        @Synchronized get() = activeSession != null
+
     fun startChecking(source: BookSource) {
         isChecking = true
         debugTimeMap[source.bookSourceUrl] = System.currentTimeMillis()
@@ -94,7 +122,8 @@ object Debug {
     }
 
     fun getRespondTime(sourceUrl: String): Long {
-        return debugTimeMap[sourceUrl] ?: CheckSource.timeout
+        val responseTime = debugTimeMap[sourceUrl]
+        return if (responseTime == null) CheckSource.timeout else responseTime
     }
 
     fun updateFinalMessage(sourceUrl: String, state: String) {
@@ -107,11 +136,13 @@ object Debug {
         }
     }
 
-    suspend fun startDebug(scope: CoroutineScope, rssSource: RssSource) {
-        cancelDebug()
-        debugSource = rssSource.sourceUrl
+    suspend fun startDebug(scope: CoroutineScope, rssSource: RssSource): Session {
+        val session = replaceSession(rssSource.sourceUrl)
         log(debugSource, "︾开始解析")
-        val sort = rssSource.sortUrls().first()
+        val sort = runCatching { rssSource.sortUrls().first() }.getOrElse {
+            log(debugSource, it.stackTraceStr, state = -1)
+            return session
+        }
         Rss.getArticles(scope, sort.first, sort.second, rssSource, 1)
             .onSuccess {
                 if (it.first.isEmpty()) {
@@ -136,12 +167,11 @@ object Debug {
             .onError {
                 log(debugSource, it.stackTraceStr, state = -1)
             }
+        return session
     }
 
-    fun startDebug(scope: CoroutineScope, rssSource: RssSource, key: String) {
-        cancelDebug()
-        debugSource = rssSource.sourceUrl
-        startTime = System.currentTimeMillis()
+    fun startDebug(scope: CoroutineScope, rssSource: RssSource, key: String): Session {
+        val session = replaceSession(rssSource.sourceUrl)
         when {
             key.contains("@js:") -> {
                 val ruleContent = rssSource.ruleContent
@@ -186,13 +216,14 @@ object Debug {
                 val searchUrl = rssSource.searchUrl
                 if (searchUrl.isNullOrEmpty()) {
                     log(debugSource, "⇒搜索URL为空", state = -1)
-                    return
+                    return session
                 }
                 log(debugSource, "⇒开始搜索关键字:$key")
                 log(debugSource, "︾开始解析搜索页")
                 rssSortDebug(scope, rssSource, "搜索", searchUrl, key)
             }
         }
+        return session
     }
 
     private fun rssSortDebug(scope: CoroutineScope, rssSource: RssSource, name: String, url: String, key: String? = null) {
@@ -239,10 +270,8 @@ object Debug {
             }
     }
 
-    fun startDebug(scope: CoroutineScope, bookSource: BookSource, key: String) {
-        cancelDebug()
-        debugSource = bookSource.bookSourceUrl
-        startTime = System.currentTimeMillis()
+    fun startDebug(scope: CoroutineScope, bookSource: BookSource, key: String): Session {
+        val session = replaceSession(bookSource.bookSourceUrl)
         when {
             key.isAbsUrl() -> {
                 val book = Book()
@@ -283,6 +312,7 @@ object Debug {
                 searchDebug(scope, bookSource, key)
             }
         }
+        return session
     }
 
     private fun exploreDebug(scope: CoroutineScope, bookSource: BookSource, url: String) {
@@ -353,10 +383,11 @@ object Debug {
                 log(debugSource, showTime = false)
                 val toc = chapters.filter { !(it.isVolume && it.url.startsWith(it.title)) }
                 if (toc.isEmpty()) {
-                    log(debugSource, "≡没有正文章节")
+                    log(debugSource, "≡没有正文章节", state = -1)
                     return@onSuccess
                 }
-                val nextChapterUrl = toc.getOrNull(1)?.url ?: toc.first().url
+                val secondChapter = toc.getOrNull(1)
+                val nextChapterUrl = if (secondChapter == null) toc.first().url else secondChapter.url
                 contentDebug(scope, bookSource, book, toc.first(), nextChapterUrl)
             }
             .onError {
@@ -388,7 +419,41 @@ object Debug {
         tasks.add(content)
     }
 
-    interface Callback {
-        fun printLog(state: Int, msg: String)
+    enum class EventKind {
+        Message, SearchSource, InfoSource, TocSource, ContentSource, Error, Completed;
+
+        val isSourcePayload: Boolean
+            get() = this == SearchSource || this == InfoSource || this == TocSource || this == ContentSource
+        val isTerminal: Boolean get() = this == Error || this == Completed
+    }
+
+    data class Event(
+        val kind: EventKind,
+        val message: String,
+        val timestamp: Long,
+        val elapsedMillis: Long,
+    )
+
+    class Session internal constructor(
+        internal val id: Long,
+        val sourceUrl: String,
+    ) {
+        private val channel = Channel<Event>(Channel.UNLIMITED)
+        val events: Flow<Event> = channel.receiveAsFlow()
+
+        internal fun emit(event: Event) {
+            channel.trySend(event)
+            if (event.kind == EventKind.Error || event.kind == EventKind.Completed) {
+                if (activeSession?.id == id) {
+                    activeSession = null
+                    debugSource = null
+                }
+                close()
+            }
+        }
+
+        internal fun close() { channel.close() }
+
+        fun cancel() { Debug.cancel(this) }
     }
-}
+}

+ 2 - 2
app/src/main/java/io/legado/app/model/webBook/BookList.kt

@@ -289,7 +289,7 @@ object BookList {
     }
 
     private fun checkExploreJson(bookSource: BookSource) {
-        if (Debug.callback == null) {
+        if (!Debug.hasActiveSession) {
             return
         }
         val json = bookSource.exploreKindsJson()
@@ -305,4 +305,4 @@ object BookList {
         }
     }
 
-}
+}

+ 26 - 226
app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugActivity.kt

@@ -1,235 +1,35 @@
 package io.legado.app.ui.book.source.debug
 
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.widget.EditText
-import androidx.activity.viewModels
-import androidx.appcompat.widget.SearchView
-import androidx.lifecycle.lifecycleScope
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.databinding.ActivitySourceDebugBinding
-import io.legado.app.help.source.clearExploreKindsCache
-import io.legado.app.help.source.exploreKinds
-import io.legado.app.lib.dialogs.selector
-//import io.legado.app.lib.theme.accentColor
-//import io.legado.app.lib.theme.primaryColor
-import io.legado.app.ui.qrcode.QrCodeResult
-import io.legado.app.ui.widget.dialog.TextDialog
-import io.legado.app.utils.applyNavigationBarPadding
-import io.legado.app.utils.gone
-import io.legado.app.utils.launch
-import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.showHelp
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
+import io.legado.app.base.BaseComposeActivity
 import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import io.legado.app.utils.visible
-import kotlinx.coroutines.launch
-import splitties.views.onClick
-import splitties.views.onLongClick
-
-class BookSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, BookSourceDebugModel>() {
-
-    override val binding by viewBinding(ActivitySourceDebugBinding::inflate)
-    override val viewModel by viewModels<BookSourceDebugModel>()
-
-    private val adapter by lazy { BookSourceDebugAdapter(this) }
-    private val searchView: SearchView by lazy {
-        binding.titleBar.findViewById(R.id.search_view)
-    }
-    private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
-        it?.let {
-            startSearch(it)
-        }
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        initRecyclerView()
-        initSearchView()
-        viewModel.init(intent.getStringExtra("key")) {
-            initHelpView()
-        }
-        viewModel.observe { state, msg ->
-            lifecycleScope.launch {
-                adapter.addItem(msg)
-                if (state == -1 || state == 1000) {
-                    binding.rotateLoading.gone()
-                }
-            }
-        }
-    }
-
-    private fun initRecyclerView() {
-        //binding.recyclerView.setEdgeEffectColor(primaryColor)
-        binding.recyclerView.adapter = adapter
-        binding.recyclerView.applyNavigationBarPadding()
-        //binding.rotateLoading.loadingColor = accentColor
-    }
-
-    private fun initSearchView() {
-        searchView.onActionViewExpanded()
-        searchView.isSubmitButtonEnabled = true
-        searchView.queryHint = getString(R.string.search_book_key)
-        searchView.contentDescription = getString(R.string.search_book_key)
-        searchView.findViewById<EditText>(androidx.appcompat.R.id.search_src_text)?.apply {
-            hint = getString(R.string.search_book_key)
-            contentDescription = getString(R.string.search_book_key)
-        }
-        searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
-            override fun onQueryTextSubmit(query: String?): Boolean {
-                searchView.clearFocus()
-                openOrCloseHelp(false)
-                startSearch(query ?: "我的")
-                return true
-            }
-
-            override fun onQueryTextChange(newText: String?): Boolean {
-                return false
-            }
-        })
-        searchView.setOnQueryTextFocusChangeListener { _, hasFocus ->
-            openOrCloseHelp(hasFocus)
-        }
-        openOrCloseHelp(true)
-    }
-
-    @SuppressLint("SetTextI18n")
-    private fun initHelpView() {
-        viewModel.bookSource?.ruleSearch?.checkKeyWord?.let {
-            if (it.isNotBlank()) {
-                binding.textMy.text = it
-            }
-        }
-        updateHelpContentDescriptions()
-        binding.textMy.onClick {
-            searchView.setQuery(binding.textMy.text, true)
-        }
-        binding.textXt.onClick {
-            searchView.setQuery(binding.textXt.text, true)
-        }
-        binding.textFx.onClick {
-            if (!binding.textFx.text.startsWith("ERROR:")) {
-                searchView.setQuery(binding.textFx.text, true)
-            }
-        }
-        binding.textInfo.onClick {
-            if (!searchView.query.isNullOrBlank()) {
-                searchView.setQuery(searchView.query, true)
-            }
-        }
-        binding.textToc.onClick {
-            prefixAutoComplete("++")
-        }
-        binding.textContent.onClick {
-            prefixAutoComplete("--")
-        }
-        initExploreKinds()
-    }
-    @SuppressLint("SetTextI18n")
-    private fun initExploreKinds() {
-        lifecycleScope.launch {
-            try {
-                val exploreKinds = viewModel.bookSource?.exploreKinds()?.filter {
-                    !it.url.isNullOrBlank()
-                }
-                exploreKinds?.firstOrNull()?.let {
-                    binding.textFx.text = "${it.title}::${it.url}"
-                    updateHelpContentDescriptions()
-                    if (it.title.startsWith("ERROR:")) {
-                        adapter.addItem("获取发现出错\n${it.url}")
-                        openOrCloseHelp(false)
-                        searchView.clearFocus()
-                        return@launch
-
-                    }
-                }
-                @Suppress("USELESS_ELVIS")
-                exploreKinds?.map { it.title ?: "" }?.let { exploreKindTitles ->
-                    binding.textFx.onLongClick {
-                        selector("选择发现", exploreKindTitles) { _, index ->
-                            val explore = exploreKinds[index]
-                            binding.textFx.text = "${explore.title}::${explore.url}"
-                            updateHelpContentDescriptions()
-                            searchView.setQuery(binding.textFx.text, true)
-                        }
-                    }
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
+
+class BookSourceDebugActivity : BaseComposeActivity() {
+    @Composable
+    override fun Content() {
+        val viewModel = koinViewModel<BookSourceDebugViewModel>()
+        val state = viewModel.uiState.collectAsStateWithLifecycle().value
+        LaunchedEffect(Unit) {
+            viewModel.onIntent(BookSourceDebugIntent.Load(intent.getStringExtra("key")))
+            viewModel.effects.collectLatest { effect ->
+                when (effect) {
+                    is BookSourceDebugEffect.ShowMessage -> toastOnUi(effect.message)
                 }
-            } catch (e: NullPointerException) {
-                adapter.addItem("获取发现出错 JSON 数据错误\n$e")
-                openOrCloseHelp(false)
-                searchView.clearFocus()
             }
         }
-    }
-
-    private fun updateHelpContentDescriptions() = binding.run {
-        textMy.contentDescription = getString(R.string.a11y_debug_example, textMy.text)
-        textXt.contentDescription = getString(R.string.a11y_debug_example, textXt.text)
-        textFx.contentDescription = getString(R.string.a11y_debug_example, textFx.text)
-        textInfo.contentDescription = getString(R.string.a11y_debug_example, textInfo.text)
-        textToc.contentDescription = getString(R.string.a11y_debug_example, textToc.text)
-        textContent.contentDescription = getString(R.string.a11y_debug_example, textContent.text)
-    }
-
-    private fun prefixAutoComplete(prefix: String) {
-        val query = searchView.query
-        if (query.isNullOrBlank() || query.length <= 2) {
-            searchView.setQuery(prefix, false)
-        } else {
-            if (!query.startsWith(prefix)) {
-                searchView.setQuery("$prefix$query", true)
-            } else {
-                searchView.setQuery(query, true)
-            }
+        LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
+            viewModel.onIntent(BookSourceDebugIntent.Stop)
         }
+        BookSourceDebugScreen(
+            state = state,
+            onIntent = viewModel::onIntent,
+            onBack = ::finish,
+        )
     }
-
-    /**
-     * 打开关闭历史界面
-     */
-    private fun openOrCloseHelp(open: Boolean) {
-        if (open) {
-            binding.help.visibility = View.VISIBLE
-        } else {
-            binding.help.visibility = View.GONE
-        }
-    }
-
-    private fun startSearch(key: String) {
-        adapter.clearItems()
-        viewModel.startDebug(key, {
-            binding.rotateLoading.visible()
-        }, {
-            toastOnUi("未获取到书源")
-        })
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.book_source_debug, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_scan -> qrCodeResult.launch()
-            R.id.menu_search_src -> showDialogFragment(TextDialog("html", viewModel.searchSrc))
-            R.id.menu_book_src -> showDialogFragment(TextDialog("html", viewModel.bookSrc))
-            R.id.menu_toc_src -> showDialogFragment(TextDialog("html", viewModel.tocSrc))
-            R.id.menu_content_src -> showDialogFragment(TextDialog("html", viewModel.contentSrc))
-            R.id.menu_refresh_explore -> lifecycleScope.launch {
-                viewModel.bookSource?.clearExploreKindsCache()
-                adapter.clearItems()
-                openOrCloseHelp(true)
-                initExploreKinds()
-            }
-
-            R.id.menu_help -> showHelp("debugHelp")
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
 }

+ 0 - 44
app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugAdapter.kt

@@ -1,44 +0,0 @@
-package io.legado.app.ui.book.source.debug
-
-import android.content.Context
-import android.view.View
-import android.view.ViewGroup
-import io.legado.app.R
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.databinding.ItemLogBinding
-
-class BookSourceDebugAdapter(context: Context) :
-    RecyclerAdapter<String, ItemLogBinding>(context) {
-
-    override fun getViewBinding(parent: ViewGroup): ItemLogBinding {
-        return ItemLogBinding.inflate(inflater, parent, false)
-    }
-
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemLogBinding,
-        item: String,
-        payloads: MutableList<Any>
-    ) {
-        binding.apply {
-            if (textView.getTag(R.id.tag1) == null) {
-                val listener = object : View.OnAttachStateChangeListener {
-                    override fun onViewAttachedToWindow(v: View) {
-                        textView.isCursorVisible = false
-                        textView.isCursorVisible = true
-                    }
-
-                    override fun onViewDetachedFromWindow(v: View) {}
-                }
-                textView.addOnAttachStateChangeListener(listener)
-                textView.setTag(R.id.tag1, listener)
-            }
-            textView.text = item
-        }
-    }
-
-    override fun registerListener(holder: ItemViewHolder, binding: ItemLogBinding) {
-        //nothing
-    }
-}

+ 56 - 0
app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugContract.kt

@@ -0,0 +1,56 @@
+package io.legado.app.ui.book.source.debug
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import io.legado.app.model.Debug
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+enum class BookSourceDebugTarget { Search, Explore, Info, Toc, Content }
+enum class BookSourceDebugStatus { Loading, Idle, Running, Success, Failed, Cancelled }
+enum class BookSourceDebugFilter { All, Messages, Sources, Errors }
+
+@Immutable
+data class BookSourceDebugEntryUi(
+    val id: Long,
+    val kind: Debug.EventKind,
+    val message: String,
+    val timestamp: Long,
+    val elapsedMillis: Long,
+)
+
+@Immutable
+data class BookSourceDebugExampleUi(
+    val title: String,
+    val target: BookSourceDebugTarget,
+    val value: String,
+)
+
+@Stable
+data class BookSourceDebugUiState(
+    val sourceName: String = "",
+    val query: String = "",
+    val target: BookSourceDebugTarget = BookSourceDebugTarget.Search,
+    val status: BookSourceDebugStatus = BookSourceDebugStatus.Loading,
+    val filter: BookSourceDebugFilter = BookSourceDebugFilter.All,
+    val entries: ImmutableList<BookSourceDebugEntryUi> = persistentListOf(),
+    val examples: ImmutableList<BookSourceDebugExampleUi> = persistentListOf(),
+    val selectedEntryId: Long? = null,
+)
+
+sealed interface BookSourceDebugIntent {
+    data class Load(val sourceUrl: String?) : BookSourceDebugIntent
+    data class SetQuery(val value: String) : BookSourceDebugIntent
+    data class SelectTarget(val target: BookSourceDebugTarget) : BookSourceDebugIntent
+    data class SelectFilter(val filter: BookSourceDebugFilter) : BookSourceDebugIntent
+    data class UseExample(val example: BookSourceDebugExampleUi) : BookSourceDebugIntent
+    data class ShowEntry(val id: Long) : BookSourceDebugIntent
+    data object DismissEntry : BookSourceDebugIntent
+    data object Start : BookSourceDebugIntent
+    data object Stop : BookSourceDebugIntent
+    data object Clear : BookSourceDebugIntent
+}
+
+sealed interface BookSourceDebugEffect {
+    data class ShowMessage(val message: String) : BookSourceDebugEffect
+}

+ 0 - 60
app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugModel.kt

@@ -1,60 +0,0 @@
-package io.legado.app.ui.book.source.debug
-
-import android.app.Application
-import io.legado.app.base.BaseViewModel
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.BookSource
-import io.legado.app.model.Debug
-
-class BookSourceDebugModel(application: Application) : BaseViewModel(application),
-    Debug.Callback {
-
-    var bookSource: BookSource? = null
-    private var callback: ((Int, String) -> Unit)? = null
-    var searchSrc: String? = null
-    var bookSrc: String? = null
-    var tocSrc: String? = null
-    var contentSrc: String? = null
-
-    fun init(sourceUrl: String?, finally: () -> Unit) {
-        sourceUrl?.let {
-            //优先使用这个,不会抛出异常
-            execute {
-                bookSource = appDb.bookSourceDao.getBookSource(sourceUrl)
-            }.onFinally {
-                finally.invoke()
-            }
-        }
-    }
-
-    fun observe(callback: (Int, String) -> Unit) {
-        this.callback = callback
-    }
-
-    fun startDebug(key: String, start: (() -> Unit)? = null, error: (() -> Unit)? = null) {
-        execute {
-            Debug.callback = this@BookSourceDebugModel
-            Debug.startDebug(this, bookSource!!, key)
-        }.onStart {
-            start?.invoke()
-        }.onError {
-            error?.invoke()
-        }
-    }
-
-    override fun printLog(state: Int, msg: String) {
-        when (state) {
-            10 -> searchSrc = msg
-            20 -> bookSrc = msg
-            30 -> tocSrc = msg
-            40 -> contentSrc = msg
-            else -> callback?.invoke(state, msg)
-        }
-    }
-
-    override fun onCleared() {
-        super.onCleared()
-        Debug.cancelDebug(true)
-    }
-
-}

+ 327 - 0
app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugScreen.kt

@@ -0,0 +1,327 @@
+package io.legado.app.ui.book.source.debug
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ClearAll
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.model.Debug
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.fadingEdge
+import io.legado.app.ui.widget.components.AppFloatingActionButton
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.EmptyMessage
+import io.legado.app.ui.widget.components.button.ToggleChip
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.utils.LogUtils
+import java.util.Date
+
+@Composable
+fun BookSourceDebugScreen(
+    state: BookSourceDebugUiState,
+    onIntent: (BookSourceDebugIntent) -> Unit,
+    onBack: () -> Unit,
+) {
+    BackHandler(onBack = onBack)
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val listState = rememberLazyListState()
+    val visibleEntries = state.entries.filter { entry ->
+        when (state.filter) {
+            BookSourceDebugFilter.All -> true
+            BookSourceDebugFilter.Messages -> entry.kind == Debug.EventKind.Message || entry.kind == Debug.EventKind.Completed
+            BookSourceDebugFilter.Sources -> entry.kind.isSourcePayload
+            BookSourceDebugFilter.Errors -> entry.kind == Debug.EventKind.Error
+        }
+    }
+    LaunchedEffect(visibleEntries.size, state.status) {
+        if (state.status == BookSourceDebugStatus.Running && visibleEntries.isNotEmpty()) {
+            listState.animateScrollToItem(visibleEntries.lastIndex)
+        }
+    }
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = "调试",
+                navigationIcon = { TopBarNavigationButton(onClick = onBack) },
+                actions = {
+                    TopBarActionButton(
+                        onClick = { onIntent(BookSourceDebugIntent.Clear) },
+                        imageVector = Icons.Default.ClearAll,
+                        contentDescription = "清空日志",
+                    )
+                },
+                scrollBehavior = scrollBehavior,
+            )
+        },
+        floatingActionButton = {
+            AppFloatingActionButton(
+                onClick = {
+                    onIntent(
+                        if (state.status == BookSourceDebugStatus.Running) BookSourceDebugIntent.Stop
+                        else BookSourceDebugIntent.Start
+                    )
+                },
+                icon = if (state.status == BookSourceDebugStatus.Running) Icons.Default.Stop else Icons.Default.PlayArrow,
+                tooltipText = if (state.status == BookSourceDebugStatus.Running) "停止" else "开始调试",
+            )
+        },
+    ) { padding ->
+        LazyColumn(
+            state = listState,
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = PaddingValues(
+                start = 16.dp,
+                end = 16.dp,
+                top = padding.calculateTopPadding() + 12.dp,
+                bottom = padding.calculateBottomPadding() + 96.dp,
+            ),
+            verticalArrangement = Arrangement.spacedBy(10.dp),
+        ) {
+            item(key = "controls", contentType = "controls") {
+                DebugControls(state, onIntent)
+            }
+            item(key = "filters", contentType = "filters") {
+                DebugChipRow {
+                    items(BookSourceDebugFilter.entries, key = { it.name }) { filter ->
+                        ToggleChip(
+                            label = filter.title,
+                            selected = state.filter == filter,
+                            onToggle = { onIntent(BookSourceDebugIntent.SelectFilter(filter)) },
+                        )
+                    }
+                }
+            }
+            if (visibleEntries.isEmpty()) {
+                item(key = "empty") {
+                    EmptyMessage(
+                        message = if (state.status == BookSourceDebugStatus.Running) "等待调试日志…" else "输入内容并开始调试",
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .height(240.dp),
+                    )
+                }
+            }
+            items(visibleEntries, key = { it.id }, contentType = { it.kind }) { entry ->
+                DebugEntryCard(
+                    entry = entry,
+                    modifier = Modifier.animateItem(),
+                    onClick = { onIntent(BookSourceDebugIntent.ShowEntry(entry.id)) },
+                )
+            }
+        }
+    }
+    val selectedEntry = state.entries.firstOrNull { it.id == state.selectedEntryId }
+    AppModalBottomSheet(
+        show = selectedEntry != null,
+        onDismissRequest = { onIntent(BookSourceDebugIntent.DismissEntry) },
+        title = selectedEntry?.kind?.title,
+        animateContentSize = false,
+    ) {
+        selectedEntry?.let {
+            AppText(
+                text = it.message,
+                modifier = Modifier.fillMaxWidth(),
+                style = LegadoTheme.typography.bodyMedium,
+            )
+        }
+    }
+}
+
+@Composable
+private fun DebugControls(
+    state: BookSourceDebugUiState,
+    onIntent: (BookSourceDebugIntent) -> Unit
+) {
+    val visibleExamples = remember(state.examples, state.target) {
+        state.examples.filter { it.target == state.target }.take(8)
+    }
+    GlassCard {
+        Column(
+            modifier = Modifier.padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.Center
+            ) {
+                AppText(
+                    text = state.sourceName,
+                    style = LegadoTheme.typography.labelMediumEmphasized
+                )
+            }
+            DebugChipRow {
+                items(BookSourceDebugTarget.entries, key = { it.name }) { target ->
+                    ToggleChip(
+                        label = target.title,
+                        selected = state.target == target,
+                        onToggle = { onIntent(BookSourceDebugIntent.SelectTarget(target)) },
+                    )
+                }
+            }
+            AppTextField(
+                value = state.query,
+                onValueChange = { onIntent(BookSourceDebugIntent.SetQuery(it)) },
+                label = state.target.hint,
+                modifier = Modifier.fillMaxWidth(),
+                enabled = state.status != BookSourceDebugStatus.Loading,
+                maxLines = 4,
+            )
+            if (visibleExamples.isNotEmpty()) {
+                DebugChipRow {
+                    items(visibleExamples, key = { "${it.target}:${it.value}" }) { example ->
+                        ToggleChip(
+                            label = example.title,
+                            selected = state.target == example.target && state.query == example.value,
+                            onToggle = { onIntent(BookSourceDebugIntent.UseExample(example)) },
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun DebugChipRow(content: LazyListScope.() -> Unit) {
+    val state = rememberLazyListState()
+    LazyRow(
+        state = state,
+        modifier = Modifier
+            .fillMaxWidth()
+            .fadingEdge(state, gradientWidth = 16.dp),
+        horizontalArrangement = Arrangement.spacedBy(8.dp),
+        content = content,
+    )
+}
+
+@Composable
+fun DebugEntryCard(
+    entry: BookSourceDebugEntryUi,
+    modifier: Modifier = Modifier,
+    onClick: () -> Unit,
+) {
+    val containerColor = when (entry.kind) {
+        Debug.EventKind.Message -> LegadoTheme.colorScheme.surfaceContainerHigh
+        Debug.EventKind.SearchSource,
+        Debug.EventKind.InfoSource,
+        Debug.EventKind.TocSource,
+        Debug.EventKind.ContentSource -> LegadoTheme.colorScheme.primaryContainer
+        Debug.EventKind.Error -> LegadoTheme.colorScheme.errorContainer
+        Debug.EventKind.Completed -> LegadoTheme.colorScheme.tertiaryContainer
+    }
+    val accentColor = when (entry.kind) {
+        Debug.EventKind.Message -> LegadoTheme.colorScheme.onSurfaceVariant
+        Debug.EventKind.SearchSource,
+        Debug.EventKind.InfoSource,
+        Debug.EventKind.TocSource,
+        Debug.EventKind.ContentSource -> LegadoTheme.colorScheme.onPrimaryContainer
+        Debug.EventKind.Error -> LegadoTheme.colorScheme.onErrorContainer
+        Debug.EventKind.Completed -> LegadoTheme.colorScheme.onTertiaryContainer
+    }
+    GlassCard(
+        modifier = modifier,
+        onClick = onClick,
+        containerColor = containerColor,
+        contentColor = accentColor,
+    ) {
+        Column(
+            modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.SpaceBetween
+            ) {
+                AppText(
+                    text = entry.kind.title,
+                    style = LegadoTheme.typography.labelMedium,
+                    color = accentColor,
+                )
+                AppText(
+                    text = "+%.3fs".format(entry.elapsedMillis / 1000.0),
+                    style = LegadoTheme.typography.labelSmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+            AppText(
+                text = entry.message,
+                maxLines = 4,
+                overflow = TextOverflow.Ellipsis,
+                style = LegadoTheme.typography.bodySmall,
+            )
+            AppText(
+                text = LogUtils.logTimeFormat.format(Date(entry.timestamp)),
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+            )
+        }
+    }
+}
+
+private val Debug.EventKind.title: String
+    get() = when (this) {
+        Debug.EventKind.Message -> "过程"
+        Debug.EventKind.SearchSource -> "搜索/发现响应"
+        Debug.EventKind.InfoSource -> "详情响应"
+        Debug.EventKind.TocSource -> "目录响应"
+        Debug.EventKind.ContentSource -> "正文响应"
+        Debug.EventKind.Error -> "错误"
+        Debug.EventKind.Completed -> "完成"
+    }
+
+private val BookSourceDebugTarget.title
+    get() = when (this) {
+        BookSourceDebugTarget.Search -> "搜索"
+        BookSourceDebugTarget.Explore -> "发现"
+        BookSourceDebugTarget.Info -> "详情"
+        BookSourceDebugTarget.Toc -> "目录"
+        BookSourceDebugTarget.Content -> "正文"
+    }
+private val BookSourceDebugTarget.hint
+    get() = when (this) {
+        BookSourceDebugTarget.Search -> "搜索关键字"
+        BookSourceDebugTarget.Explore -> "发现 URL"
+        BookSourceDebugTarget.Info -> "详情页 URL"
+        BookSourceDebugTarget.Toc -> "目录页 URL"
+        BookSourceDebugTarget.Content -> "正文页 URL"
+    }
+private val BookSourceDebugFilter.title
+    get() = when (this) {
+        BookSourceDebugFilter.All -> "全部"
+        BookSourceDebugFilter.Messages -> "过程"
+        BookSourceDebugFilter.Sources -> "响应"
+        BookSourceDebugFilter.Errors -> "错误"
+    }

+ 150 - 0
app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugViewModel.kt

@@ -0,0 +1,150 @@
+package io.legado.app.ui.book.source.debug
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.BookSource
+import io.legado.app.help.source.exploreKinds
+import io.legado.app.model.Debug
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.concurrent.atomic.AtomicLong
+
+class BookSourceDebugViewModel(application: Application) : AndroidViewModel(application) {
+    private val _uiState = MutableStateFlow(BookSourceDebugUiState())
+    val uiState = _uiState.asStateFlow()
+    private val _effects = MutableSharedFlow<BookSourceDebugEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+    private val nextId = AtomicLong()
+    private var source: BookSource? = null
+    private var session: Debug.Session? = null
+
+    fun onIntent(intent: BookSourceDebugIntent) {
+        when (intent) {
+            is BookSourceDebugIntent.Load -> load(intent.sourceUrl)
+            is BookSourceDebugIntent.SetQuery -> _uiState.update { it.copy(query = intent.value) }
+            is BookSourceDebugIntent.SelectTarget -> _uiState.update { it.copy(target = intent.target) }
+            is BookSourceDebugIntent.SelectFilter -> _uiState.update { it.copy(filter = intent.filter) }
+            is BookSourceDebugIntent.UseExample -> _uiState.update {
+                it.copy(target = intent.example.target, query = intent.example.value)
+            }
+            is BookSourceDebugIntent.ShowEntry -> _uiState.update { it.copy(selectedEntryId = intent.id) }
+            BookSourceDebugIntent.DismissEntry -> _uiState.update { it.copy(selectedEntryId = null) }
+            BookSourceDebugIntent.Start -> start()
+            BookSourceDebugIntent.Stop -> stop()
+            BookSourceDebugIntent.Clear -> _uiState.update { it.copy(entries = persistentListOf()) }
+        }
+    }
+
+    private fun load(sourceUrl: String?) = viewModelScope.launch {
+        val loaded: BookSource? = withContext(Dispatchers.IO) {
+            sourceUrl?.let { appDb.bookSourceDao.getBookSource(it) }
+        }
+        source = loaded
+        if (loaded == null) {
+            _uiState.update { it.copy(status = BookSourceDebugStatus.Failed) }
+            _effects.tryEmit(BookSourceDebugEffect.ShowMessage("未获取到书源"))
+            return@launch
+        }
+        val examples = buildList<BookSourceDebugExampleUi> {
+            val configuredKeyword = loaded.ruleSearch?.checkKeyWord
+            val keyword = if (configuredKeyword.isNullOrBlank()) "我的" else configuredKeyword
+            add(BookSourceDebugExampleUi(keyword, BookSourceDebugTarget.Search, keyword))
+            add(BookSourceDebugExampleUi("系统", BookSourceDebugTarget.Search, "系统"))
+            val kinds = try { loaded.exploreKinds() } catch (_: Exception) { emptyList() }
+            kinds.filter { !it.url.isNullOrBlank() }.forEach { kind ->
+                add(BookSourceDebugExampleUi(kind.title, BookSourceDebugTarget.Explore, kind.url.orEmpty()))
+            }
+        }.toImmutableList()
+        _uiState.update {
+            it.copy(
+                sourceName = loaded.bookSourceName,
+                query = examples.firstOrNull()?.value.orEmpty(),
+                status = BookSourceDebugStatus.Idle,
+                examples = examples,
+            )
+        }
+    }
+
+    private fun start() {
+        val currentSource = source
+        if (currentSource == null) {
+            _effects.tryEmit(BookSourceDebugEffect.ShowMessage("未获取到书源"))
+            return
+        }
+        val state = _uiState.value
+        if (state.query.isBlank()) {
+            _effects.tryEmit(BookSourceDebugEffect.ShowMessage("调试内容不能为空")); return
+        }
+        session?.cancel()
+        _uiState.update { it.copy(entries = persistentListOf(), status = BookSourceDebugStatus.Running) }
+        val key = when (state.target) {
+            BookSourceDebugTarget.Search -> state.query
+            BookSourceDebugTarget.Explore -> "发现::${state.query}"
+            BookSourceDebugTarget.Info -> state.query
+            BookSourceDebugTarget.Toc -> "++${state.query.removePrefix("++")}"
+            BookSourceDebugTarget.Content -> "--${state.query.removePrefix("--")}"
+        }
+        session = Debug.startDebug(viewModelScope, currentSource, key).also(::collectSession)
+    }
+
+    private fun stop() {
+        session?.cancel()
+        session = null
+        _uiState.update { state ->
+            if (state.status == BookSourceDebugStatus.Running) state.copy(status = BookSourceDebugStatus.Cancelled)
+            else state
+        }
+    }
+
+    private fun collectSession(debugSession: Debug.Session) {
+        viewModelScope.launch {
+            debugSession.events.collect(::onDebugEvent)
+            if (session === debugSession) {
+                session = null
+                _uiState.update { state ->
+                    if (state.status == BookSourceDebugStatus.Running) {
+                        state.copy(status = BookSourceDebugStatus.Cancelled)
+                    } else state
+                }
+            }
+        }
+    }
+
+    private fun onDebugEvent(event: Debug.Event) {
+        _uiState.update { state ->
+            val entry = BookSourceDebugEntryUi(
+                id = nextId.incrementAndGet(),
+                kind = event.kind,
+                message = event.message,
+                timestamp = event.timestamp,
+                elapsedMillis = event.elapsedMillis,
+            )
+            state.copy(
+                entries = (state.entries + entry).takeLast(MAX_LOG_ENTRIES).toImmutableList(),
+                status = when (event.kind) {
+                    Debug.EventKind.Error -> BookSourceDebugStatus.Failed
+                    Debug.EventKind.Completed -> BookSourceDebugStatus.Success
+                    else -> state.status
+                },
+            )
+        }
+    }
+
+    override fun onCleared() {
+        session?.cancel()
+        session = null
+        super.onCleared()
+    }
+
+    companion object { private const val MAX_LOG_ENTRIES = 1_000 }
+}

+ 15 - 130
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt

@@ -1,135 +1,20 @@
 package io.legado.app.ui.rss.source.debug
 
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import androidx.appcompat.widget.SearchView
-import androidx.activity.viewModels
-import androidx.lifecycle.lifecycleScope
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.databinding.ActivitySourceDebugBinding
-import io.legado.app.help.source.sortUrls
-import io.legado.app.ui.widget.dialog.TextDialog
-import io.legado.app.utils.applyNavigationBarPadding
-import io.legado.app.utils.gone
-import io.legado.app.utils.showDialogFragment
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
+import io.legado.app.base.BaseComposeActivity
 import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import io.legado.app.utils.visible
-import kotlinx.coroutines.launch
-import splitties.views.onClick
-import splitties.views.onLongClick
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
 
-
-class RssSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, RssSourceDebugModel>() {
-
-    override val binding by viewBinding(ActivitySourceDebugBinding::inflate)
-    override val viewModel by viewModels<RssSourceDebugModel>()
-
-    private val adapter by lazy { RssSourceDebugAdapter(this) }
-
-    @SuppressLint("SetTextI18n")
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        initRecyclerView()
-        initSearchView()
-        initHelpView()
-        viewModel.observe { state, msg ->
-            lifecycleScope.launch {
-                adapter.addItem(msg)
-                if (state == -1 || state == 1000) {
-                    binding.rotateLoading.gone()
-                }
-            }
-        }
-        viewModel.initData(intent.getStringExtra("key")) {
-            // initData 完成后的回调
-        }
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.rss_source_debug, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_list_src -> showDialogFragment(TextDialog("Html", viewModel.listSrc))
-            R.id.menu_content_src -> showDialogFragment(TextDialog("Html", viewModel.contentSrc))
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    private fun initRecyclerView() {
-        binding.recyclerView.adapter = adapter
-        binding.recyclerView.applyNavigationBarPadding()
-    }
-
-    private fun initSearchView() {
-        val searchView = binding.titleBar.findViewById<SearchView>(R.id.search_view)
-        searchView.visible()
-        searchView.isSubmitButtonEnabled = true
-        searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
-            override fun onQueryTextSubmit(query: String?): Boolean {
-                searchView.clearFocus()
-                startDebug(query ?: "")
-                return true
-            }
-
-            override fun onQueryTextChange(newText: String?): Boolean {
-                return false
-            }
-        })
-    }
-
-    @SuppressLint("SetTextI18n")
-    private fun initHelpView() {
-        binding.help.visible()
-        binding.textMy.onClick {
-            binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(binding.textMy.text, true)
-        }
-        binding.textXt.onClick {
-            binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(binding.textXt.text, true)
-        }
-        binding.textFx.onClick {
-            if (!binding.textFx.text.startsWith("ERROR:")) {
-                binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(binding.textFx.text, true)
-            }
-        }
-        binding.textContent.onClick {
-            val query = binding.titleBar.findViewById<SearchView>(R.id.search_view).query
-            if (!query.isNullOrBlank()) {
-                binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(query, true)
-            }
-        }
-        initSortKinds()
-    }
-
-    private fun initSortKinds() {
-        lifecycleScope.launch {
-            val sortKinds = viewModel.rssSource?.sortUrls()?.filter {
-                it.second.isNotBlank()
-            }
-            sortKinds?.firstOrNull()?.let {
-                binding.textFx.text = "${it.first}::${it.second}"
-                if (it.first.startsWith("ERROR:")) {
-                    adapter.addItem("获取发现出错\n${it.second}")
-                    binding.titleBar.findViewById<SearchView>(R.id.search_view).clearFocus()
-                    return@launch
-                }
-            }
-        }
-    }
-
-    private fun startDebug(key: String) {
-        adapter.clearItems()
-        binding.help.gone() // 关闭调试选项面板
-        viewModel.rssSource?.let {
-            binding.rotateLoading.visible()
-            viewModel.startDebug(it, key)
-        } ?: toastOnUi(R.string.error_no_source)
+class RssSourceDebugActivity : BaseComposeActivity() {
+    @Composable override fun Content() {
+        val vm = koinViewModel<RssSourceDebugViewModel>(); val state = vm.uiState.collectAsStateWithLifecycle().value
+        LaunchedEffect(Unit) { vm.onIntent(RssSourceDebugIntent.Load(intent.getStringExtra("key"))); vm.effects.collectLatest { if (it is RssSourceDebugEffect.ShowMessage) toastOnUi(it.value) } }
+        LifecycleEventEffect(Lifecycle.Event.ON_STOP) { vm.onIntent(RssSourceDebugIntent.Stop) }
+        RssSourceDebugScreen(state, vm::onIntent, ::finish)
     }
-}
+}

+ 0 - 44
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugAdapter.kt

@@ -1,44 +0,0 @@
-package io.legado.app.ui.rss.source.debug
-
-import android.content.Context
-import android.view.View
-import android.view.ViewGroup
-import io.legado.app.R
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.databinding.ItemLogBinding
-
-class RssSourceDebugAdapter(context: Context) :
-    RecyclerAdapter<String, ItemLogBinding>(context) {
-
-    override fun getViewBinding(parent: ViewGroup): ItemLogBinding {
-        return ItemLogBinding.inflate(inflater, parent, false)
-    }
-
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemLogBinding,
-        item: String,
-        payloads: MutableList<Any>
-    ) {
-        binding.apply {
-            if (textView.getTag(R.id.tag1) == null) {
-                val listener = object : View.OnAttachStateChangeListener {
-                    override fun onViewAttachedToWindow(v: View) {
-                        textView.isCursorVisible = false
-                        textView.isCursorVisible = true
-                    }
-
-                    override fun onViewDetachedFromWindow(v: View) {}
-                }
-                textView.addOnAttachStateChangeListener(listener)
-                textView.setTag(R.id.tag1, listener)
-            }
-            textView.text = item
-        }
-    }
-
-    override fun registerListener(holder: ItemViewHolder, binding: ItemLogBinding) {
-        //nothing
-    }
-}

+ 40 - 0
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugContract.kt

@@ -0,0 +1,40 @@
+package io.legado.app.ui.rss.source.debug
+
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.Immutable
+import io.legado.app.ui.book.source.debug.BookSourceDebugEntryUi
+import io.legado.app.ui.book.source.debug.BookSourceDebugFilter
+import io.legado.app.ui.book.source.debug.BookSourceDebugStatus
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+enum class RssSourceDebugTarget { Search, Sort, Content }
+
+@Stable data class RssSourceDebugUiState(
+    val sourceName: String = "",
+    val query: String = "",
+    val target: RssSourceDebugTarget = RssSourceDebugTarget.Search,
+    val status: BookSourceDebugStatus = BookSourceDebugStatus.Loading,
+    val filter: BookSourceDebugFilter = BookSourceDebugFilter.All,
+    val entries: ImmutableList<BookSourceDebugEntryUi> = persistentListOf(),
+    val examples: ImmutableList<RssSourceDebugExampleUi> = persistentListOf(),
+    val selectedEntryId: Long? = null,
+)
+
+@Immutable
+data class RssSourceDebugExampleUi(val title: String, val target: RssSourceDebugTarget, val value: String)
+
+sealed interface RssSourceDebugIntent {
+    data class Load(val sourceUrl: String?) : RssSourceDebugIntent
+    data class SetQuery(val value: String) : RssSourceDebugIntent
+    data class SelectTarget(val value: RssSourceDebugTarget) : RssSourceDebugIntent
+    data class SelectFilter(val value: BookSourceDebugFilter) : RssSourceDebugIntent
+    data class UseExample(val value: RssSourceDebugExampleUi) : RssSourceDebugIntent
+    data class ShowEntry(val id: Long) : RssSourceDebugIntent
+    data object DismissEntry : RssSourceDebugIntent
+    data object Start : RssSourceDebugIntent
+    data object Stop : RssSourceDebugIntent
+    data object Clear : RssSourceDebugIntent
+}
+
+sealed interface RssSourceDebugEffect { data class ShowMessage(val value: String) : RssSourceDebugEffect }

+ 0 - 59
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt

@@ -1,59 +0,0 @@
-package io.legado.app.ui.rss.source.debug
-
-import android.app.Application
-import io.legado.app.base.BaseViewModel
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.RssSource
-import io.legado.app.model.Debug
-
-class RssSourceDebugModel(application: Application) : BaseViewModel(application),
-    Debug.Callback {
-    var rssSource: RssSource? = null
-    private var callback: ((Int, String) -> Unit)? = null
-    var listSrc: String? = null
-    var contentSrc: String? = null
-
-    fun initData(sourceUrl: String?, finally: () -> Unit) {
-        sourceUrl?.let {
-            execute {
-                rssSource = appDb.rssSourceDao.getByKey(sourceUrl)
-            }.onFinally {
-                finally()
-            }
-        }
-    }
-
-    fun observe(callback: (Int, String) -> Unit) {
-        this.callback = callback
-    }
-
-    fun startDebug(source: RssSource) {
-        execute {
-            Debug.callback = this@RssSourceDebugModel
-            Debug.startDebug(this, source)
-        }
-    }
-
-    fun startDebug(source: RssSource, key: String) {
-        execute {
-            Debug.callback = this@RssSourceDebugModel
-            Debug.startDebug(this, source, key)
-        }.onStart {
-            // 可以在这里添加开始回调
-        }
-    }
-
-    override fun printLog(state: Int, msg: String) {
-        when (state) {
-            10 -> listSrc = msg
-            20 -> contentSrc = msg
-            else -> callback?.invoke(state, msg)
-        }
-    }
-
-    override fun onCleared() {
-        super.onCleared()
-        Debug.cancelDebug(true)
-    }
-
-}

+ 194 - 0
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugScreen.kt

@@ -0,0 +1,194 @@
+package io.legado.app.ui.rss.source.debug
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ClearAll
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.unit.dp
+import io.legado.app.model.Debug
+import io.legado.app.ui.book.source.debug.BookSourceDebugFilter
+import io.legado.app.ui.book.source.debug.BookSourceDebugStatus
+import io.legado.app.ui.book.source.debug.DebugEntryCard
+import io.legado.app.ui.book.source.debug.DebugChipRow
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppFloatingActionButton
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.EmptyMessage
+import io.legado.app.ui.widget.components.button.ToggleChip
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+
+@Composable
+fun RssSourceDebugScreen(
+    state: RssSourceDebugUiState,
+    onIntent: (RssSourceDebugIntent) -> Unit,
+    onBack: () -> Unit
+) {
+    val scroll = GlassTopAppBarDefaults.defaultScrollBehavior();
+    val list = rememberLazyListState()
+    val visibleExamples = remember(state.examples, state.target) {
+        state.examples.filter { it.target == state.target }.take(8)
+    }
+    val shown = state.entries.filter {
+        when (state.filter) {
+            BookSourceDebugFilter.All -> true
+            BookSourceDebugFilter.Messages -> it.kind == Debug.EventKind.Message || it.kind == Debug.EventKind.Completed
+            BookSourceDebugFilter.Sources -> it.kind.isSourcePayload
+            BookSourceDebugFilter.Errors -> it.kind == Debug.EventKind.Error
+        }
+    }
+    LaunchedEffect(shown.size) {
+        if (state.status == BookSourceDebugStatus.Running && shown.isNotEmpty()) list.animateScrollToItem(
+            shown.lastIndex
+        )
+    }
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = "调试",
+                navigationIcon = { TopBarNavigationButton(onClick = onBack) },
+                actions = {
+                    TopBarActionButton(
+                        { onIntent(RssSourceDebugIntent.Clear) },
+                        Icons.Default.ClearAll,
+                        "清空日志"
+                    )
+                },
+                scrollBehavior = scroll,
+            )
+        },
+        floatingActionButton = {
+            AppFloatingActionButton(
+                onClick = { onIntent(if (state.status == BookSourceDebugStatus.Running) RssSourceDebugIntent.Stop else RssSourceDebugIntent.Start) },
+                icon = if (state.status == BookSourceDebugStatus.Running) Icons.Default.Stop else Icons.Default.PlayArrow,
+                tooltipText = if (state.status == BookSourceDebugStatus.Running) "停止" else "开始调试",
+            )
+        },
+    ) { padding ->
+        LazyColumn(
+            state = list, modifier = Modifier.fillMaxSize(),
+            contentPadding = PaddingValues(
+                16.dp,
+                padding.calculateTopPadding() + 12.dp,
+                16.dp,
+                padding.calculateBottomPadding() + 96.dp
+            ),
+            verticalArrangement = Arrangement.spacedBy(10.dp),
+        ) {
+            item("controls") {
+                GlassCard {
+                    Column(
+                        Modifier.padding(12.dp),
+                        verticalArrangement = Arrangement.spacedBy(8.dp)
+                    ) {
+                        Row(
+                            modifier = Modifier.fillMaxWidth(),
+                            verticalAlignment = Alignment.CenterVertically,
+                            horizontalArrangement = Arrangement.Center,
+                        ) {
+                            AppText(
+                                text = state.sourceName,
+                                style = LegadoTheme.typography.labelMediumEmphasized,
+                            )
+                        }
+                        DebugChipRow {
+                            items(RssSourceDebugTarget.entries, key = { it.name }) { target ->
+                                ToggleChip(
+                                    target.label,
+                                    state.target == target,
+                                    { onIntent(RssSourceDebugIntent.SelectTarget(target)) })
+                            }
+                        }
+                        AppTextField(
+                            value = state.query,
+                            onValueChange = { onIntent(RssSourceDebugIntent.SetQuery(it)) },
+                            modifier = Modifier.fillMaxWidth(),
+                            label = state.target.hint,
+                            maxLines = 4,
+                        )
+                        if (visibleExamples.isNotEmpty()) {
+                            DebugChipRow {
+                                items(visibleExamples, key = { "${it.target}:${it.value}" }) { example ->
+                                    ToggleChip(
+                                        example.title,
+                                        state.query == example.value,
+                                        { onIntent(RssSourceDebugIntent.UseExample(example)) })
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+            item("filters") {
+                DebugChipRow {
+                    items(BookSourceDebugFilter.entries, key = { it.name }) { filter ->
+                        ToggleChip(
+                            filter.label,
+                            state.filter == filter,
+                            { onIntent(RssSourceDebugIntent.SelectFilter(filter)) })
+                    }
+                }
+            }
+            if (shown.isEmpty()) item("empty") {
+                EmptyMessage(
+                    message = "输入内容并开始调试",
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .height(240.dp),
+                )
+            }
+            items(shown, key = { it.id }) { entry ->
+                DebugEntryCard(
+                    entry = entry,
+                    modifier = Modifier.animateItem(),
+                    onClick = { onIntent(RssSourceDebugIntent.ShowEntry(entry.id)) },
+                )
+            }
+        }
+    }
+    val selected = state.entries.firstOrNull { it.id == state.selectedEntryId }
+    AppModalBottomSheet(
+        selected != null,
+        { onIntent(RssSourceDebugIntent.DismissEntry) },
+        title = "日志详情",
+        animateContentSize = false
+    ) { selected?.let { AppText(it.message, Modifier.fillMaxWidth()) } }
+}
+
+private val RssSourceDebugTarget.label
+    get() = when (this) {
+        RssSourceDebugTarget.Search -> "搜索"; RssSourceDebugTarget.Sort -> "分类"; RssSourceDebugTarget.Content -> "内容"
+    }
+private val RssSourceDebugTarget.hint
+    get() = when (this) {
+        RssSourceDebugTarget.Search -> "搜索关键字"; RssSourceDebugTarget.Sort -> "分类 URL"; RssSourceDebugTarget.Content -> "内容页 URL 或 @js 链接"
+    }
+private val BookSourceDebugFilter.label
+    get() = when (this) {
+        BookSourceDebugFilter.All -> "全部"; BookSourceDebugFilter.Messages -> "过程"; BookSourceDebugFilter.Sources -> "响应"; BookSourceDebugFilter.Errors -> "错误"
+    }

+ 97 - 0
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugViewModel.kt

@@ -0,0 +1,97 @@
+package io.legado.app.ui.rss.source.debug
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.RssSource
+import io.legado.app.help.source.sortUrls
+import io.legado.app.model.Debug
+import io.legado.app.ui.book.source.debug.BookSourceDebugEntryUi
+import io.legado.app.ui.book.source.debug.BookSourceDebugStatus
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.concurrent.atomic.AtomicLong
+
+class RssSourceDebugViewModel(app: Application) : AndroidViewModel(app) {
+    private val _uiState = MutableStateFlow(RssSourceDebugUiState())
+    val uiState = _uiState.asStateFlow()
+    private val _effects = MutableSharedFlow<RssSourceDebugEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+    private var source: RssSource? = null
+    private var session: Debug.Session? = null
+    private val ids = AtomicLong()
+
+    fun onIntent(intent: RssSourceDebugIntent) = when (intent) {
+        is RssSourceDebugIntent.Load -> { load(intent.sourceUrl); Unit }
+        is RssSourceDebugIntent.SetQuery -> _uiState.update { it.copy(query = intent.value) }
+        is RssSourceDebugIntent.SelectTarget -> _uiState.update { it.copy(target = intent.value) }
+        is RssSourceDebugIntent.SelectFilter -> _uiState.update { it.copy(filter = intent.value) }
+        is RssSourceDebugIntent.UseExample -> _uiState.update { it.copy(target = intent.value.target, query = intent.value.value) }
+        is RssSourceDebugIntent.ShowEntry -> _uiState.update { it.copy(selectedEntryId = intent.id) }
+        RssSourceDebugIntent.DismissEntry -> _uiState.update { it.copy(selectedEntryId = null) }
+        RssSourceDebugIntent.Start -> start()
+        RssSourceDebugIntent.Stop -> stop()
+        RssSourceDebugIntent.Clear -> _uiState.update { it.copy(entries = persistentListOf()) }
+    }
+
+    private fun load(url: String?) = viewModelScope.launch {
+        val loaded = withContext(Dispatchers.IO) { url?.let { appDb.rssSourceDao.getByKey(it) } }
+        source = loaded
+        if (loaded == null) {
+            _uiState.update { it.copy(status = BookSourceDebugStatus.Failed) }
+            _effects.tryEmit(RssSourceDebugEffect.ShowMessage("未获取到订阅源"))
+            return@launch
+        }
+        val examples = buildList {
+            add(RssSourceDebugExampleUi("我的", RssSourceDebugTarget.Search, "我的"))
+            add(RssSourceDebugExampleUi("系统", RssSourceDebugTarget.Search, "系统"))
+            runCatching { loaded.sortUrls() }.getOrNull()?.filter { it.second.isNotBlank() }?.forEach {
+                add(RssSourceDebugExampleUi(it.first, RssSourceDebugTarget.Sort, it.second))
+            }
+        }.toImmutableList()
+        _uiState.update { it.copy(sourceName = loaded.sourceName, query = "我的", examples = examples, status = BookSourceDebugStatus.Idle) }
+    }
+
+    private fun start() {
+        val rss = source ?: run {
+            _effects.tryEmit(RssSourceDebugEffect.ShowMessage("未获取到订阅源"))
+            return
+        }
+        val state = _uiState.value
+        if (state.query.isBlank()) { _effects.tryEmit(RssSourceDebugEffect.ShowMessage("调试内容不能为空")); return }
+        session?.cancel()
+        _uiState.update { it.copy(entries = persistentListOf(), status = BookSourceDebugStatus.Running) }
+        val key = if (state.target == RssSourceDebugTarget.Sort) "分类::${state.query}" else state.query
+        session = Debug.startDebug(viewModelScope, rss, key).also(::collectSession)
+    }
+    private fun stop() {
+        session?.cancel()
+        session = null
+        _uiState.update { state ->
+            if (state.status == BookSourceDebugStatus.Running) {
+                state.copy(status = BookSourceDebugStatus.Cancelled)
+            } else state
+        }
+    }
+    private fun collectSession(debugSession: Debug.Session) {
+        viewModelScope.launch {
+            debugSession.events.collect(::onDebugEvent)
+            if (session === debugSession) {
+                session = null
+                _uiState.update { state -> if (state.status == BookSourceDebugStatus.Running) state.copy(status = BookSourceDebugStatus.Cancelled) else state }
+            }
+        }
+    }
+    private fun onDebugEvent(event: Debug.Event) {
+        _uiState.update { state -> state.copy(
+            entries = (state.entries + BookSourceDebugEntryUi(ids.incrementAndGet(), event.kind, event.message, event.timestamp, event.elapsedMillis)).takeLast(1000).toImmutableList(),
+            status = when (event.kind) { Debug.EventKind.Error -> BookSourceDebugStatus.Failed; Debug.EventKind.Completed -> BookSourceDebugStatus.Success; else -> state.status }
+        ) }
+    }
+    override fun onCleared() { session?.cancel(); session = null; super.onCleared() }
+}

+ 24 - 458
app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt

@@ -1,468 +1,34 @@
 package io.legado.app.ui.rss.source.edit
 
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.widget.EditText
-import androidx.activity.viewModels
+import android.content.Intent
+import androidx.compose.runtime.*
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.lifecycleScope
-import com.google.android.material.tabs.TabLayout
-import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
 import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.data.entities.RssSource
-import io.legado.app.databinding.ActivityRssSourceEditBinding
-import io.legado.app.help.config.LocalConfig
-import io.legado.app.lib.dialogs.SelectItem
-import io.legado.app.lib.dialogs.alert
-//import io.legado.app.lib.theme.accentColor
-//import io.legado.app.lib.theme.backgroundColor
-//import io.legado.app.lib.theme.primaryColor
-import io.legado.app.ui.file.HandleFileContract
+import io.legado.app.base.BaseComposeActivity
+import io.legado.app.data.appDb
+import io.legado.app.ui.about.AppLogDialog
 import io.legado.app.ui.login.SourceLoginActivity
-import io.legado.app.ui.qrcode.QrCodeResult
 import io.legado.app.ui.rss.source.debug.RssSourceDebugActivity
-import io.legado.app.ui.widget.dialog.UrlOptionDialog
 import io.legado.app.ui.widget.dialog.VariableDialog
-import io.legado.app.ui.widget.keyboard.KeyboardToolPop
-import io.legado.app.ui.widget.text.EditEntity
-import io.legado.app.utils.GSON
-import io.legado.app.utils.imeHeight
-import io.legado.app.utils.isContentScheme
-import io.legado.app.utils.isTrue
-import io.legado.app.utils.launch
-import io.legado.app.utils.navigationBarHeight
-import io.legado.app.utils.sendToClip
-import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
-import io.legado.app.utils.share
-import io.legado.app.utils.shareWithQr
-import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.showHelp
-import io.legado.app.utils.startActivity
-import io.legado.app.utils.viewbindingdelegate.viewBinding
+import io.legado.app.utils.*
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
-import splitties.views.bottomPadding
-
-class RssSourceEditActivity :
-    VMBaseActivity<ActivityRssSourceEditBinding, RssSourceEditViewModel>(),
-    KeyboardToolPop.CallBack,
-    VariableDialog.Callback {
-
-    override val binding by viewBinding(ActivityRssSourceEditBinding::inflate)
-    override val viewModel by viewModels<RssSourceEditViewModel>()
-    private val softKeyboardTool by lazy {
-        KeyboardToolPop(this, lifecycleScope, binding.root, this)
-    }
-    private val adapter by lazy { RssSourceEditAdapter() }
-    private val sourceEntities: ArrayList<EditEntity> = ArrayList()
-    private val startEntities: ArrayList<EditEntity> = ArrayList()
-    private val listEntities: ArrayList<EditEntity> = ArrayList()
-    private val webViewEntities: ArrayList<EditEntity> = ArrayList()
-    private val selectDoc = registerForActivityResult(HandleFileContract()) {
-        it.uri?.let { uri ->
-            if (uri.isContentScheme()) {
-                sendText(uri.toString())
-            } else {
-                sendText(uri.path.toString())
-            }
-        }
-    }
-    private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
-        it?.let {
-            viewModel.importSource(it) { source: RssSource ->
-                upSourceView(source)
-            }
-        }
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        softKeyboardTool.attachToWindow(window)
-        initView()
-        viewModel.initData(intent) {
-            upSourceView(viewModel.rssSource)
-        }
-    }
-
-    override fun onPostCreate(savedInstanceState: Bundle?) {
-        super.onPostCreate(savedInstanceState)
-        if (!LocalConfig.ruleHelpVersionIsLast) {
-            showHelp("ruleHelp")
-        }
-    }
-
-    override fun finish() {
-        val source = getRssSource()
-        if (!source.equal(viewModel.rssSource ?: RssSource())) {
-            alert(R.string.exit) {
-                setMessage(R.string.exit_no_save)
-                positiveButton(R.string.yes)
-                negativeButton(R.string.no) {
-                    super.finish()
-                }
-            }
-        } else {
-            super.finish()
-        }
-    }
-
-    override fun onDestroy() {
-        super.onDestroy()
-        softKeyboardTool.dismiss()
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.source_edit, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
-        menu.findItem(R.id.menu_login)?.isVisible = !viewModel.rssSource?.loginUrl.isNullOrBlank()
-        menu.findItem(R.id.menu_auto_complete)?.isChecked = viewModel.autoComplete
-        return super.onMenuOpened(featureId, menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_save -> viewModel.save(getRssSource()) {
-                setResult(RESULT_OK)
-                finish()
-            }
-
-            R.id.menu_debug_source -> viewModel.save(getRssSource()) { source ->
-                startActivity<RssSourceDebugActivity> {
-                    putExtra("key", source.sourceUrl)
-                }
-            }
-
-            R.id.menu_login -> viewModel.save(getRssSource()) {
-                startActivity<SourceLoginActivity> {
-                    putExtra("type", "rssSource")
-                    putExtra("key", it.sourceUrl)
-                }
-            }
-
-            R.id.menu_set_source_variable -> setSourceVariable()
-            R.id.menu_clear_cookie -> viewModel.clearCookie(getRssSource().sourceUrl)
-            R.id.menu_auto_complete -> viewModel.autoComplete = !viewModel.autoComplete
-            R.id.menu_copy_source -> sendToClip(GSON.toJson(getRssSource()))
-            R.id.menu_qr_code_camera -> qrCodeResult.launch()
-            R.id.menu_paste_source -> viewModel.pasteSource { upSourceView(it) }
-            R.id.menu_share_str -> share(GSON.toJson(getRssSource()))
-            R.id.menu_share_qr -> shareWithQr(
-                GSON.toJson(getRssSource()),
-                getString(R.string.share_rss_source),
-                ErrorCorrectionLevel.L
-            )
-
-            R.id.menu_log -> showDialogFragment<io.legado.app.ui.about.AppLogDialog>()
-
-            R.id.menu_help -> showHelp("ruleHelp")
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    private fun initView() {
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_base)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_start)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_list)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            text = "WEB_VIEW"
-        })
-//        binding.recyclerView.setEdgeEffectColor(primaryColor)
-        binding.recyclerView.adapter = adapter
-//        binding.tabLayout.setBackgroundColor(backgroundColor)
-//        binding.tabLayout.setSelectedTabIndicatorColor(accentColor)
-        binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
-            override fun onTabReselected(tab: TabLayout.Tab?) {
-
-            }
-
-            override fun onTabUnselected(tab: TabLayout.Tab?) {
-
-            }
-
-            override fun onTabSelected(tab: TabLayout.Tab?) {
-                setEditEntities(tab?.position)
-            }
-        })
-        binding.recyclerView.setOnApplyWindowInsetsListenerCompat { view, windowInsets ->
-            val navigationBarHeight = windowInsets.navigationBarHeight
-            val imeHeight = windowInsets.imeHeight
-            view.bottomPadding = if (imeHeight == 0) navigationBarHeight else 0
-            softKeyboardTool.initialPadding = imeHeight
-            windowInsets
-        }
-    }
-
-    private fun setEditEntities(tabPosition: Int?) {
-        when (tabPosition) {
-            1 -> adapter.editEntities = startEntities
-            2 -> adapter.editEntities = listEntities
-            3 -> adapter.editEntities = webViewEntities
-            else -> adapter.editEntities = sourceEntities
-        }
-        binding.recyclerView.scrollToPosition(0)
-    }
-
-    private fun upSourceView(rssSource: RssSource?) {
-        val rs = rssSource ?: RssSource()
-        rs.let {
-            binding.cbIsEnable.isChecked = rs.enabled
-            binding.cbSingleUrl.isChecked = rs.singleUrl
-            binding.cbIsEnableCookie.isChecked = rs.enabledCookieJar == true
-            binding.cbIsEnablePreload.isChecked = rs.preload
-            val typeCount = binding.spType.adapter?.count ?: 0
-            if (typeCount > 0 && rs.type !in 0..<typeCount) {
-                rs.type = 0
-            }
-            if (typeCount > 0) {
-                binding.spType.setSelection(rs.type)
-            }
-            val styleCount = binding.lyType.adapter?.count ?: 0
-            if (styleCount > 0 && rs.articleStyle !in 0..<styleCount) {
-                rs.articleStyle = 0
-            }
-            if (styleCount > 0) {
-                binding.lyType.setSelection(rs.articleStyle)
-            }
-        }
-        sourceEntities.clear()
-        sourceEntities.apply {
-            add(EditEntity("sourceName", rs.sourceName, R.string.source_name))
-            add(EditEntity("sourceUrl", rs.sourceUrl, R.string.source_url))
-            add(EditEntity("sourceIcon", rs.sourceIcon, R.string.source_icon))
-            add(EditEntity("sourceGroup", rs.sourceGroup, R.string.source_group))
-            add(EditEntity("sourceComment", rs.sourceComment, R.string.comment))
-            add(EditEntity("searchUrl", rs.searchUrl, R.string.r_search_url))
-            add(EditEntity("sortUrl", rs.sortUrl, R.string.sort_url))
-            add(EditEntity("loginUrl", rs.loginUrl, R.string.login_url))
-            add(EditEntity("loginUi", rs.loginUi, R.string.login_ui))
-            add(EditEntity("loginCheckJs", rs.loginCheckJs, R.string.login_check_js))
-            add(EditEntity("coverDecodeJs", rs.coverDecodeJs, R.string.cover_decode_js))
-            add(EditEntity("header", rs.header, R.string.source_http_header))
-            add(EditEntity("variableComment", rs.variableComment, R.string.variable_comment))
-            add(EditEntity("concurrentRate", rs.concurrentRate, R.string.concurrent_rate))
-            add(EditEntity("jsLib", rs.jsLib, "jsLib"))
-        }
-        startEntities.clear()
-        startEntities.apply {
-            add(EditEntity("startHtml", rs.startHtml, R.string.r_startHtml))
-            add(EditEntity("startStyle", rs.startStyle, R.string.r_startStyle))
-            add(EditEntity("startJs", rs.startJs, R.string.r_startJs))
-            add(EditEntity("preloadJs", rs.preloadJs, R.string.r_preloadJs))
-        }
-        listEntities.clear()
-        listEntities.apply {
-            add(EditEntity("ruleArticles", rs.ruleArticles, R.string.r_articles))
-            add(EditEntity("ruleNextPage", rs.ruleNextPage, R.string.r_next))
-            add(EditEntity("ruleTitle", rs.ruleTitle, R.string.r_title))
-            add(EditEntity("rulePubDate", rs.rulePubDate, R.string.r_date))
-            add(EditEntity("ruleDescription", rs.ruleDescription, R.string.r_description))
-            add(EditEntity("ruleImage", rs.ruleImage, R.string.r_image))
-            add(EditEntity("ruleLink", rs.ruleLink, R.string.r_link))
-        }
-        webViewEntities.clear()
-        webViewEntities.apply {
-            add(
-                EditEntity(
-                    "enableJs",
-                    rs.enableJs.toString(),
-                    R.string.enable_js,
-                    EditEntity.ViewType.checkBox
-                )
-            )
-            add(
-                EditEntity(
-                    "loadWithBaseUrl",
-                    rs.loadWithBaseUrl.toString(),
-                    R.string.load_with_base_url,
-                    EditEntity.ViewType.checkBox
-                )
-            )
-            add(
-                EditEntity(
-                    "showWebLog",
-                    rs.showWebLog.toString(),
-                    R.string.load_with_web_log,
-                    EditEntity.ViewType.checkBox
-                )
-            )
-            add(
-                EditEntity(
-                    "cacheFirst",
-                    rs.cacheFirst.toString(),
-                    R.string.cache_first,
-                    EditEntity.ViewType.checkBox
-                )
-            )
-            add(EditEntity("ruleContent", rs.ruleContent, R.string.r_content))
-            add(EditEntity("style", rs.style, R.string.r_style))
-            add(EditEntity("injectJs", rs.injectJs, R.string.r_inject_js))
-            add(EditEntity("contentWhitelist", rs.contentWhitelist, R.string.c_whitelist))
-            add(EditEntity("contentBlacklist", rs.contentBlacklist, R.string.c_blacklist))
-            add(
-                EditEntity(
-                    "shouldOverrideUrlLoading",
-                    rs.shouldOverrideUrlLoading,
-                    "url跳转拦截(js, 返回true拦截,js变量url,可以通过js打开url,比如调用阅读搜索,添加书架等,简化规则写法,不用webView js注入)"
-                )
-            )
-        }
-        binding.tabLayout.selectTab(binding.tabLayout.getTabAt(0))
-        setEditEntities(0)
-    }
-
-    private fun getRssSource(): RssSource {
-        val source = viewModel.rssSource?.copy() ?: RssSource()
-        source.enabled = binding.cbIsEnable.isChecked
-        source.singleUrl = binding.cbSingleUrl.isChecked
-        source.enabledCookieJar = binding.cbIsEnableCookie.isChecked
-        source.preload = binding.cbIsEnablePreload.isChecked
-        source.type = binding.spType.selectedItemPosition
-        source.articleStyle = binding.lyType.selectedItemPosition
-        sourceEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "sourceName" -> source.sourceName = it.value ?: ""
-                "sourceUrl" -> source.sourceUrl = it.value ?: ""
-                "sourceIcon" -> source.sourceIcon = it.value ?: ""
-                "sourceGroup" -> source.sourceGroup = it.value
-                "sourceComment" -> source.sourceComment = it.value
-                "loginUrl" -> source.loginUrl = it.value
-                "loginUi" -> source.loginUi = it.value
-                "loginCheckJs" -> source.loginCheckJs = it.value
-                "coverDecodeJs" -> source.coverDecodeJs = it.value
-                "header" -> source.header = it.value
-                "variableComment" -> source.variableComment = it.value
-                "concurrentRate" -> source.concurrentRate = it.value
-                "searchUrl" -> source.searchUrl = it.value
-                "sortUrl" -> source.sortUrl = it.value
-                "jsLib" -> source.jsLib = it.value
-            }
-        }
-        startEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "startHtml" -> source.startHtml = it.value
-                "startStyle" -> source.startStyle = it.value
-                "startJs" -> source.startJs = it.value
-                "preloadJs" -> source.preloadJs = it.value
-            }
-        }
-        listEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "ruleArticles" -> source.ruleArticles = it.value
-                "ruleNextPage" -> source.ruleNextPage =
-                    viewModel.ruleComplete(it.value, source.ruleArticles, 2)
-
-                "ruleTitle" -> source.ruleTitle =
-                    viewModel.ruleComplete(it.value, source.ruleArticles)
-
-                "rulePubDate" -> source.rulePubDate =
-                    viewModel.ruleComplete(it.value, source.ruleArticles)
-
-                "ruleDescription" -> source.ruleDescription =
-                    viewModel.ruleComplete(it.value, source.ruleArticles)
-
-                "ruleImage" -> source.ruleImage =
-                    viewModel.ruleComplete(it.value, source.ruleArticles, 3)
-
-                "ruleLink" -> source.ruleLink =
-                    viewModel.ruleComplete(it.value, source.ruleArticles)
-            }
-        }
-        webViewEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "enableJs" -> source.enableJs = it.value.isTrue()
-                "loadWithBaseUrl" -> source.loadWithBaseUrl = it.value.isTrue()
-                "showWebLog" -> source.showWebLog = it.value.isTrue()
-                "cacheFirst" -> source.cacheFirst = it.value.isTrue()
-                "ruleContent" -> source.ruleContent =
-                    viewModel.ruleComplete(it.value, source.ruleArticles)
-
-                "style" -> source.style = it.value
-                "injectJs" -> source.injectJs = it.value
-                "contentWhitelist" -> source.contentWhitelist = it.value
-                "contentBlacklist" -> source.contentBlacklist = it.value
-                "shouldOverrideUrlLoading" -> source.shouldOverrideUrlLoading = it.value
-            }
-        }
-        return source
-    }
-
-    private fun setSourceVariable() {
-        viewModel.save(getRssSource()) { source ->
-            lifecycleScope.launch {
-                val comment =
-                    source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
-                val variable = withContext(Dispatchers.IO) { source.getVariable() }
-                showDialogFragment(
-                    VariableDialog(
-                        getString(R.string.set_source_variable),
-                        source.getKey(),
-                        variable,
-                        comment
-                    )
-                )
-            }
-        }
-    }
-
-    override fun setVariable(key: String, variable: String?) {
-        viewModel.rssSource?.setVariable(variable)
-    }
-
-    override fun helpActions(): List<SelectItem<String>> {
-        return arrayListOf(
-            SelectItem("插入URL参数", "urlOption"),
-            SelectItem("订阅源教程", "ruleHelp"),
-            SelectItem("js教程", "jsHelp"),
-            SelectItem("正则教程", "regexHelp"),
-            SelectItem("选择文件", "selectFile"),
-        )
-    }
-
-    override fun onHelpActionSelect(action: String) {
-        when (action) {
-            "urlOption" -> UrlOptionDialog(this) {
-                sendText(it)
-            }.show()
-
-            "ruleHelp" -> showHelp("ruleHelp")
-            "jsHelp" -> showHelp("jsHelp")
-            "regexHelp" -> showHelp("regexHelp")
-            "selectFile" -> selectDoc.launch {
-                mode = HandleFileContract.FILE
-            }
-        }
-    }
-
-    override fun sendText(text: String) {
-        if (text.isBlank()) return
-        val view = window.decorView.findFocus()
-        if (view is EditText) {
-            val start = view.selectionStart
-            val end = view.selectionEnd
-            val edit = view.editableText//获取EditText的文字
-            if (start < 0 || start >= edit.length) {
-                edit.append(text)
-            } else if (start > end) {
-                edit.replace(end, start, text)
-            } else {
-                edit.replace(start, end, text)//光标所在位置插入文字
-            }
-        }
-    }
-
-}
+import org.koin.androidx.compose.koinViewModel
+
+class RssSourceEditActivity : BaseComposeActivity(), VariableDialog.Callback {
+ @Composable override fun Content(){val vm=koinViewModel<RssSourceEditViewModel>();val state=vm.uiState.collectAsStateWithLifecycle().value
+  LaunchedEffect(Unit){vm.onIntent(RssSourceEditIntent.Load(intent.getStringExtra("sourceUrl")));vm.effects.collectLatest{e->when(e){
+   is RssSourceEditEffect.Finish->{if(e.url.isNotEmpty())setResult(RESULT_OK,Intent().putExtra("origin",e.url));finish()}
+   is RssSourceEditEffect.Debug->startActivity<RssSourceDebugActivity>{putExtra("key",e.url)}
+   is RssSourceEditEffect.Login->startActivity<SourceLoginActivity>{putExtra("type","rssSource");putExtra("key",e.url)}
+   is RssSourceEditEffect.Copy->sendToClip(e.text);is RssSourceEditEffect.Share->share(e.text);RssSourceEditEffect.ReadClipboard->{val t=getClipText();if(t.isNullOrBlank())toastOnUi("剪贴板为空")else vm.onIntent(RssSourceEditIntent.Import(t))}
+   is RssSourceEditEffect.Variable->openVariable(e.url);RssSourceEditEffect.Log->showDialogFragment<AppLogDialog>();RssSourceEditEffect.Help->showHelp("ruleHelp");is RssSourceEditEffect.Message->toastOnUi(e.text)
+  }}}
+  RssSourceEditScreen(state,vm::onIntent){vm.onIntent(RssSourceEditIntent.Back)}
+ }
+ private fun openVariable(url:String)=lifecycleScope.launch{val s=withContext(Dispatchers.IO){appDb.rssSourceDao.getByKey(url)}?:return@launch;showDialogFragment(VariableDialog(getString(R.string.set_source_variable),s.getKey(),withContext(Dispatchers.IO){s.getVariable()},s.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")))}
+ override fun setVariable(key:String,variable:String?){lifecycleScope.launch(Dispatchers.IO){appDb.rssSourceDao.getByKey(key)?.setVariable(variable)}}
+}

+ 0 - 132
app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditAdapter.kt

@@ -1,132 +0,0 @@
-package io.legado.app.ui.rss.source.edit
-
-import android.annotation.SuppressLint
-import android.text.Editable
-import android.text.TextWatcher
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import io.legado.app.R
-import io.legado.app.databinding.ItemSourceEditBinding
-import io.legado.app.databinding.ItemSourceEditCheckBoxBinding
-import io.legado.app.help.config.AppConfig
-import io.legado.app.ui.widget.code.addJsPattern
-import io.legado.app.ui.widget.code.addJsonPattern
-import io.legado.app.ui.widget.code.addLegadoPattern
-import io.legado.app.ui.widget.text.EditEntity
-import io.legado.app.utils.isTrue
-
-class RssSourceEditAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
-
-    val editEntityMaxLine = AppConfig.sourceEditMaxLine
-
-    var editEntities: ArrayList<EditEntity> = ArrayList()
-        @SuppressLint("NotifyDataSetChanged")
-        set(value) {
-            field = value
-            notifyDataSetChanged()
-        }
-
-    override fun getItemViewType(position: Int): Int {
-        val item = editEntities[position]
-        return item.viewType
-    }
-
-    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
-        return when (viewType) {
-            EditEntity.ViewType.checkBox -> {
-                val binding = ItemSourceEditCheckBoxBinding
-                    .inflate(LayoutInflater.from(parent.context), parent, false)
-                CheckBoxViewHolder(binding)
-            }
-            else -> {
-                val binding = ItemSourceEditBinding
-                    .inflate(LayoutInflater.from(parent.context), parent, false)
-                binding.editText.addLegadoPattern()
-                binding.editText.addJsonPattern()
-                binding.editText.addJsPattern()
-                EditTextViewHolder(binding)
-            }
-        }
-    }
-
-    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
-        when (holder) {
-            is CheckBoxViewHolder -> holder.bind(editEntities[position])
-            is EditTextViewHolder -> holder.bind(editEntities[position])
-        }
-    }
-
-    override fun getItemCount(): Int {
-        return editEntities.size
-    }
-
-    inner class EditTextViewHolder(val binding: ItemSourceEditBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-
-        fun bind(editEntity: EditEntity) = binding.run {
-            editText.maxLines = editEntityMaxLine
-            if (editText.getTag(R.id.tag1) == null) {
-                val listener = object : View.OnAttachStateChangeListener {
-                    override fun onViewAttachedToWindow(v: View) {
-                        editText.isCursorVisible = false
-                        editText.isCursorVisible = true
-                        editText.isFocusable = true
-                        editText.isFocusableInTouchMode = true
-                    }
-
-                    override fun onViewDetachedFromWindow(v: View) {
-
-                    }
-                }
-                editText.addOnAttachStateChangeListener(listener)
-                editText.setTag(R.id.tag1, listener)
-            }
-            editText.getTag(R.id.tag2)?.let {
-                if (it is TextWatcher) {
-                    editText.removeTextChangedListener(it)
-                }
-            }
-            editText.setText(editEntity.value)
-            textInputLayout.hint = editEntity.hint
-            val textWatcher = object : TextWatcher {
-                override fun beforeTextChanged(
-                    s: CharSequence,
-                    start: Int,
-                    count: Int,
-                    after: Int
-                ) {
-
-                }
-
-                override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
-
-                }
-
-                override fun afterTextChanged(s: Editable?) {
-                    editEntity.value = (s?.toString())
-                }
-            }
-            editText.addTextChangedListener(textWatcher)
-            editText.setTag(R.id.tag2, textWatcher)
-        }
-    }
-
-    inner class CheckBoxViewHolder(val binding: ItemSourceEditCheckBoxBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-
-        fun bind(editEntity: EditEntity) = binding.run {
-            checkBox.setOnCheckedChangeListener(null)
-            checkBox.text = editEntity.hint
-            checkBox.isChecked = editEntity.value.isTrue()
-            checkBox.setOnCheckedChangeListener { _, isChecked ->
-                editEntity.value = isChecked.toString()
-            }
-        }
-
-
-    }
-
-
-}

+ 36 - 0
app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditContract.kt

@@ -0,0 +1,36 @@
+package io.legado.app.ui.rss.source.edit
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.*
+import kotlinx.collections.immutable.*
+import io.legado.app.ui.book.source.edit.BookSourceEditFieldUi
+
+enum class RssSourceEditTab(@StringRes val title: Int) {
+    Base(io.legado.app.R.string.source_tab_base),
+    Start(io.legado.app.R.string.source_tab_start),
+    List(io.legado.app.R.string.source_tab_list),
+    WebView(io.legado.app.R.string.source_tab_web_view),
+}
+@Stable data class RssSourceEditUiState(
+    val loading: Boolean = true, val selectedTab: RssSourceEditTab = RssSourceEditTab.Base,
+    val fields: ImmutableMap<RssSourceEditTab, ImmutableList<BookSourceEditFieldUi>> = persistentMapOf(),
+    val enabled: Boolean = true, val singleUrl: Boolean = false, val cookieJar: Boolean = true,
+    val preload: Boolean = false, val type: Int = 0, val articleStyle: Int = 0,
+    val autoComplete: Boolean = false, val dirty: Boolean = false, val selectedField: String? = null,
+)
+sealed interface RssSourceEditIntent {
+    data class Load(val url: String?) : RssSourceEditIntent; data class SelectTab(val tab: RssSourceEditTab) : RssSourceEditIntent
+    data class UpdateField(val key: String, val value: String) : RssSourceEditIntent; data class EditField(val key: String?) : RssSourceEditIntent
+    data class SetEnabled(val value: Boolean) : RssSourceEditIntent; data class SetSingleUrl(val value: Boolean) : RssSourceEditIntent
+    data class SetCookieJar(val value: Boolean) : RssSourceEditIntent; data class SetPreload(val value: Boolean) : RssSourceEditIntent
+    data class SetType(val value: Int) : RssSourceEditIntent; data class SetArticleStyle(val value: Int) : RssSourceEditIntent
+    data object ToggleAutoComplete : RssSourceEditIntent; data object Save : RssSourceEditIntent; data object SaveDebug : RssSourceEditIntent
+    data object SaveLogin : RssSourceEditIntent; data object Copy : RssSourceEditIntent; data object Paste : RssSourceEditIntent
+    data class Import(val text: String) : RssSourceEditIntent; data object Share : RssSourceEditIntent; data object ClearCookie : RssSourceEditIntent
+    data object SetVariable : RssSourceEditIntent; data object ShowLog : RssSourceEditIntent; data object Help : RssSourceEditIntent; data object Back : RssSourceEditIntent
+}
+sealed interface RssSourceEditEffect {
+    data class Finish(val url: String) : RssSourceEditEffect; data class Debug(val url: String) : RssSourceEditEffect; data class Login(val url: String) : RssSourceEditEffect
+    data class Copy(val text: String) : RssSourceEditEffect; data class Share(val text: String) : RssSourceEditEffect; data object ReadClipboard : RssSourceEditEffect
+    data class Variable(val url: String) : RssSourceEditEffect; data object Log : RssSourceEditEffect; data object Help : RssSourceEditEffect; data class Message(val text: String) : RssSourceEditEffect
+}

+ 45 - 0
app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditScreen.kt

@@ -0,0 +1,45 @@
+package io.legado.app.ui.rss.source.edit
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.pager.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.book.source.edit.*
+import io.legado.app.ui.widget.components.*
+import io.legado.app.ui.widget.components.menuItem.*
+import io.legado.app.ui.widget.components.tabRow.AppTabRow
+import io.legado.app.ui.widget.components.topbar.*
+import kotlinx.coroutines.launch
+
+@Composable fun RssSourceEditScreen(state:RssSourceEditUiState,onIntent:(RssSourceEditIntent)->Unit,onBack:()->Unit){
+    BackHandler{onBack()};val tabs=RssSourceEditTab.entries;val pager=rememberPagerState(state.selectedTab.ordinal){tabs.size};val scope=rememberCoroutineScope();val scroll=GlassTopAppBarDefaults.defaultScrollBehavior();var menu by remember{mutableStateOf(false)}
+    LaunchedEffect(state.selectedTab){if(pager.currentPage!=state.selectedTab.ordinal)pager.animateScrollToPage(state.selectedTab.ordinal)}
+    LaunchedEffect(pager){snapshotFlow{pager.currentPage}.collect{onIntent(RssSourceEditIntent.SelectTab(tabs[it]))}}
+    AppScaffold(modifier=Modifier.nestedScroll(scroll.nestedScrollConnection),topBar={Column{GlassMediumFlexibleTopAppBar(
+        title=stringResource(R.string.rss_source_edit),navigationIcon={TopBarNavigationButton(onClick=onBack)},scrollBehavior=scroll,
+        actions={TopBarActionButton({onIntent(RssSourceEditIntent.SaveDebug)},Icons.Default.BugReport,stringResource(R.string.debug_source));TopBarActionButton({menu=true},Icons.Default.MoreVert,stringResource(R.string.more_menu));RssEditMenu(menu,{menu=false},onIntent)}
+    );AppTabRow(tabs.map{stringResource(it.title)},state.selectedTab.ordinal,{p->onIntent(RssSourceEditIntent.SelectTab(tabs[p]));scope.launch{pager.animateScrollToPage(p)}},Modifier.fillMaxWidth())}},
+        floatingActionButton={AppFloatingActionButton({onIntent(RssSourceEditIntent.Save)},icon=Icons.Default.Save,tooltipText=stringResource(R.string.action_save))}){padding->
+        HorizontalPager(pager,Modifier.fillMaxSize()){page->val tab=tabs[page];LazyColumn(Modifier.fillMaxSize(),contentPadding=PaddingValues(16.dp,padding.calculateTopPadding()+12.dp,16.dp,padding.calculateBottomPadding()+96.dp),verticalArrangement=Arrangement.spacedBy(10.dp)){
+            if(tab==RssSourceEditTab.Base)item("options"){RssEditOptions(state,onIntent)}
+            items(state.fields[tab].orEmpty(),key={it.path}){field->SourceEditFieldCard(field,Modifier.fillMaxWidth()){onIntent(RssSourceEditIntent.EditField(field.path))}}
+        }} }
+    val field=state.fields.values.flatten().firstOrNull{it.path==state.selectedField};SourceEditFieldSheet(field,{onIntent(RssSourceEditIntent.EditField(null))}){k,v->onIntent(RssSourceEditIntent.UpdateField(k,v))}
+}
+@Composable private fun RssEditOptions(s:RssSourceEditUiState,on:(RssSourceEditIntent)->Unit){val types=stringArrayResource(R.array.rss_type);val styles=stringArrayResource(R.array.layout_type);var typeMenu by remember{mutableStateOf(false)};var styleMenu by remember{mutableStateOf(false)}
+    Column(verticalArrangement=Arrangement.spacedBy(8.dp)){Row(horizontalArrangement=Arrangement.spacedBy(8.dp)){Box(Modifier.weight(1f)){SourceEditOptionCard("类型:${types.getOrNull(s.type).orEmpty()}",{typeMenu=true});RoundDropdownMenu(typeMenu,{typeMenu=false}){types.forEachIndexed{i,t->RoundDropdownMenuItem(text=t,isSelected=s.type==i,onClick={typeMenu=false;on(RssSourceEditIntent.SetType(i))})}}};Box(Modifier.weight(1f)){SourceEditOptionCard("布局:${styles.getOrNull(s.articleStyle).orEmpty()}",{styleMenu=true});RoundDropdownMenu(styleMenu,{styleMenu=false}){styles.forEachIndexed{i,t->RoundDropdownMenuItem(text=t,isSelected=s.articleStyle==i,onClick={styleMenu=false;on(RssSourceEditIntent.SetArticleStyle(i))})}}};SourceEditOptionCard(stringResource(R.string.is_enable),{on(RssSourceEditIntent.SetEnabled(!s.enabled))},Modifier.weight(1f),checked=s.enabled)}
+        Row(horizontalArrangement=Arrangement.spacedBy(8.dp)){SourceEditOptionCard(stringResource(R.string.single_url),{on(RssSourceEditIntent.SetSingleUrl(!s.singleUrl))},Modifier.weight(1f),checked=s.singleUrl);SourceEditOptionCard(stringResource(R.string.auto_save_cookie),{on(RssSourceEditIntent.SetCookieJar(!s.cookieJar))},Modifier.weight(1f),checked=s.cookieJar);SourceEditOptionCard(stringResource(R.string.enable_preload),{on(RssSourceEditIntent.SetPreload(!s.preload))},Modifier.weight(1f),checked=s.preload)}}
+}
+@Composable private fun RssEditMenu(show:Boolean,dismiss:()->Unit,on:(RssSourceEditIntent)->Unit){fun go(i:RssSourceEditIntent){dismiss();on(i)};RoundDropdownMenu(show,dismiss){RoundDropdownMenuItem(text=stringResource(R.string.login),onClick={go(RssSourceEditIntent.SaveLogin)});RoundDropdownMenuItem(text=stringResource(R.string.auto_complete),onClick={go(RssSourceEditIntent.ToggleAutoComplete)});RoundDropdownMenuItem(text=stringResource(R.string.copy_source),onClick={go(RssSourceEditIntent.Copy)});RoundDropdownMenuItem(text=stringResource(R.string.paste_source),onClick={go(RssSourceEditIntent.Paste)});RoundDropdownMenuItem(text=stringResource(R.string.set_source_variable),onClick={go(RssSourceEditIntent.SetVariable)});RoundDropdownMenuItem(text=stringResource(R.string.str_share),onClick={go(RssSourceEditIntent.Share)});RoundDropdownMenuItem(text=stringResource(R.string.cookie),onClick={go(RssSourceEditIntent.ClearCookie)});RoundDropdownMenuItem(text=stringResource(R.string.log),onClick={go(RssSourceEditIntent.ShowLog)});RoundDropdownMenuItem(text=stringResource(R.string.help),onClick={go(RssSourceEditIntent.Help)})}}

+ 45 - 107
app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditViewModel.kt

@@ -1,119 +1,57 @@
 package io.legado.app.ui.rss.source.edit
 
 import android.app.Application
-import android.content.Intent
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.gson.*
 import io.legado.app.R
-import io.legado.app.base.BaseViewModel
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.RssSource
 import io.legado.app.exception.NoStackTraceException
-import io.legado.app.help.AppCacheManager
-import io.legado.app.help.RuleComplete
+import io.legado.app.help.*
+import io.legado.app.help.config.SourceConfig
 import io.legado.app.help.http.CookieStore
 import io.legado.app.help.source.removeSortCache
 import io.legado.app.model.SharedJsScope
+import io.legado.app.ui.book.source.edit.BookSourceEditFieldUi
 import io.legado.app.utils.GSON
-import io.legado.app.utils.fromJsonObject
-import io.legado.app.utils.getClipText
-import io.legado.app.utils.printOnDebug
-import io.legado.app.utils.stackTraceStr
-import io.legado.app.utils.toastOnUi
+import kotlinx.collections.immutable.*
 import kotlinx.coroutines.Dispatchers
-
-
-class RssSourceEditViewModel(application: Application) : BaseViewModel(application) {
-    var autoComplete = false
-    var rssSource: RssSource? = null
-
-    fun initData(intent: Intent, onFinally: () -> Unit) {
-        execute {
-            val key = intent.getStringExtra("sourceUrl")
-            if (key != null) {
-                appDb.rssSourceDao.getByKey(key)?.let {
-                    rssSource = it
-                }
-            }
-        }.onFinally {
-            onFinally()
-        }
-    }
-
-    fun save(source: RssSource, success: ((RssSource) -> Unit)) {
-        execute {
-            if (source.sourceName.isBlank() || source.sourceName.isBlank()) {
-                throw NoStackTraceException(context.getString(R.string.non_null_name_url))
-            }
-            val oldSource = rssSource ?: RssSource()
-            if (!source.equal(oldSource)) {
-                source.lastUpdateTime = System.currentTimeMillis()
-                if (oldSource.sortUrl != source.sortUrl) {
-                    oldSource.removeSortCache()
-                }
-                if (oldSource.jsLib != source.jsLib) {
-                    SharedJsScope.remove(oldSource.jsLib)
-                }
-            }
-            rssSource?.let {
-                appDb.rssSourceDao.delete(it)
-                //更新收藏的源地址
-                if (it.sourceUrl != source.sourceUrl) {
-                    appDb.rssStarDao.updateOrigin(source.sourceUrl, it.sourceUrl)
-                    appDb.rssArticleDao.updateOrigin(source.sourceUrl, it.sourceUrl)
-                    appDb.cacheDao.deleteSourceVariables(it.sourceUrl)
-                    AppCacheManager.clearSourceVariables()
-                }
-            }
-            appDb.rssSourceDao.insert(source)
-            rssSource = source
-            source
-        }.onSuccess {
-            success(it)
-        }.onError {
-            context.toastOnUi(it.localizedMessage)
-            it.printOnDebug()
-        }
-    }
-
-    fun pasteSource(onSuccess: (source: RssSource) -> Unit) {
-        execute(context = Dispatchers.Main) {
-            var source: RssSource? = null
-            context.getClipText()?.let { json ->
-                source = GSON.fromJsonObject<RssSource>(json).getOrThrow()
-            }
-            source
-        }.onError {
-            context.toastOnUi(it.localizedMessage)
-        }.onSuccess {
-            if (it != null) {
-                onSuccess(it)
-            } else {
-                context.toastOnUi("格式不对")
-            }
-        }
-    }
-
-    fun importSource(text: String, finally: (source: RssSource) -> Unit) {
-        execute {
-            val text1 = text.trim()
-            GSON.fromJsonObject<RssSource>(text1).getOrThrow().let {
-                finally.invoke(it)
-            }
-        }.onError {
-            context.toastOnUi(it.stackTraceStr)
-        }
-    }
-
-    fun clearCookie(url: String) {
-        execute {
-            CookieStore.removeCookie(url)
-        }
-    }
-
-    fun ruleComplete(rule: String?, preRule: String? = null, type: Int = 1): String? {
-        if (autoComplete) {
-            return RuleComplete.autoComplete(rule, preRule, type)
-        }
-        return rule
-    }
-
-}
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+
+class RssSourceEditViewModel(app: Application) : AndroidViewModel(app) {
+    private val _uiState = MutableStateFlow(RssSourceEditUiState()); val uiState = _uiState.asStateFlow()
+    private val _effects = MutableSharedFlow<RssSourceEditEffect>(extraBufferCapacity = 16); val effects = _effects.asSharedFlow()
+    private var original: RssSource? = null; private var json = JsonObject(); private var baseline = ""
+    fun onIntent(i: RssSourceEditIntent) { when(i) {
+        is RssSourceEditIntent.Load -> load(i.url); is RssSourceEditIntent.SelectTab -> _uiState.update { it.copy(selectedTab=i.tab) }
+        is RssSourceEditIntent.UpdateField -> update(i.key,i.value); is RssSourceEditIntent.EditField -> _uiState.update { it.copy(selectedField=i.key) }
+        is RssSourceEditIntent.SetEnabled -> flag { copy(enabled=i.value) }; is RssSourceEditIntent.SetSingleUrl -> flag { copy(singleUrl=i.value) }
+        is RssSourceEditIntent.SetCookieJar -> flag { copy(cookieJar=i.value) }; is RssSourceEditIntent.SetPreload -> flag { copy(preload=i.value) }
+        is RssSourceEditIntent.SetType -> flag { copy(type=i.value) }; is RssSourceEditIntent.SetArticleStyle -> flag { copy(articleStyle=i.value) }
+        RssSourceEditIntent.ToggleAutoComplete -> _uiState.update { it.copy(autoComplete=!it.autoComplete) }
+        RssSourceEditIntent.Save -> save { RssSourceEditEffect.Finish(it) }; RssSourceEditIntent.SaveDebug -> save { RssSourceEditEffect.Debug(it) }
+        RssSourceEditIntent.SaveLogin -> save { RssSourceEditEffect.Login(it) }; RssSourceEditIntent.Copy -> _effects.tryEmit(RssSourceEditEffect.Copy(GSON.toJson(current())))
+        RssSourceEditIntent.Paste -> _effects.tryEmit(RssSourceEditEffect.ReadClipboard); is RssSourceEditIntent.Import -> import(i.text)
+        RssSourceEditIntent.Share -> _effects.tryEmit(RssSourceEditEffect.Share(GSON.toJson(current()))); RssSourceEditIntent.ClearCookie -> viewModelScope.launch(Dispatchers.IO){ CookieStore.removeCookie(current().sourceUrl) }
+        RssSourceEditIntent.SetVariable -> save { RssSourceEditEffect.Variable(it) }; RssSourceEditIntent.ShowLog -> _effects.tryEmit(RssSourceEditEffect.Log)
+        RssSourceEditIntent.Help -> _effects.tryEmit(RssSourceEditEffect.Help); RssSourceEditIntent.Back -> _effects.tryEmit(RssSourceEditEffect.Finish(""))
+    } }
+    private fun load(url:String?)=viewModelScope.launch(Dispatchers.IO){ apply(url?.let{appDb.rssSourceDao.getByKey(it)}?:RssSource(),true) }
+    private fun apply(s:RssSource, asOriginal:Boolean=false){ if(asOriginal){original=s;baseline=GSON.toJson(s)};json=JsonParser.parseString(GSON.toJson(s)).asJsonObject;_uiState.value=RssSourceEditUiState(false,fields=groups(),enabled=s.enabled,singleUrl=s.singleUrl,cookieJar=s.enabledCookieJar==true,preload=s.preload,type=s.type,articleStyle=s.articleStyle) }
+    private fun update(k:String,v:String){ if(v.isBlank())json.remove(k) else json.addProperty(k,v);_uiState.update{st->st.copy(fields=st.fields.mapValues{(_,fs)->fs.map{if(it.path==k)it.copy(value=v)else it}.toImmutableList()}.toImmutableMap(),dirty=true)} }
+    private fun flag(f:RssSourceEditUiState.()->RssSourceEditUiState)=_uiState.update{f(it).copy(dirty=true)}
+    private fun current():RssSource=GSON.fromJson(json,RssSource::class.java).apply{val s=_uiState.value;enabled=s.enabled;singleUrl=s.singleUrl;enabledCookieJar=s.cookieJar;preload=s.preload;type=s.type;articleStyle=s.articleStyle;if(s.autoComplete){ruleNextPage=RuleComplete.autoComplete(ruleNextPage,ruleArticles,2);ruleTitle=RuleComplete.autoComplete(ruleTitle,ruleArticles);rulePubDate=RuleComplete.autoComplete(rulePubDate,ruleArticles);ruleDescription=RuleComplete.autoComplete(ruleDescription,ruleArticles);ruleImage=RuleComplete.autoComplete(ruleImage,ruleArticles,3);ruleLink=RuleComplete.autoComplete(ruleLink,ruleArticles);ruleContent=RuleComplete.autoComplete(ruleContent,ruleArticles)}}
+    private fun save(effect:(String)->RssSourceEditEffect)=viewModelScope.launch(Dispatchers.IO){runCatching{val s=current();if(s.sourceName.isBlank()||s.sourceUrl.isBlank())throw NoStackTraceException(getApplication<Application>().getString(R.string.non_null_name_url));val old=original?:RssSource();if(!s.equal(old)){s.lastUpdateTime=System.currentTimeMillis();if(old.sortUrl!=s.sortUrl)old.removeSortCache();if(old.jsLib!=s.jsLib)SharedJsScope.remove(old.jsLib)};original?.let{appDb.rssSourceDao.delete(it);if(it.sourceUrl!=s.sourceUrl){appDb.rssStarDao.updateOrigin(s.sourceUrl,it.sourceUrl);appDb.rssArticleDao.updateOrigin(s.sourceUrl,it.sourceUrl);appDb.cacheDao.deleteSourceVariables(it.sourceUrl);AppCacheManager.clearSourceVariables()}};appDb.rssSourceDao.insert(s);original=s;baseline=GSON.toJson(s);s.sourceUrl}.onSuccess{_uiState.update{s->s.copy(dirty=false)};_effects.emit(effect(it))}.onFailure{_effects.emit(RssSourceEditEffect.Message(it.localizedMessage?:"Error"))}}
+    private fun import(text:String)=runCatching{GSON.fromJson(text,RssSource::class.java)}.onSuccess{apply(it);_uiState.update{s->s.copy(dirty=true)}}.onFailure{_effects.tryEmit(RssSourceEditEffect.Message(it.localizedMessage?:"格式不对"))}
+    private data class F(val k:String,val r:Int?=null,val l:String?=null)
+    private fun groups()=SPECS.mapValues{(_,v)->v.map{BookSourceEditFieldUi(it.k,it.r,it.l,json.get(it.k)?.takeUnless{e->e.isJsonNull}?.asString.orEmpty())}.toImmutableList()}.toImmutableMap()
+    companion object {
+        private fun f(k:String,r:Int)=F(k,r)
+        private val SPECS=mapOf(
+        RssSourceEditTab.Base to listOf(f("sourceName",R.string.source_name),f("sourceUrl",R.string.source_url),f("sourceIcon",R.string.source_icon),f("sourceGroup",R.string.source_group),f("sourceComment",R.string.comment),f("searchUrl",R.string.r_search_url),f("sortUrl",R.string.sort_url),f("loginUrl",R.string.login_url),f("loginUi",R.string.login_ui),f("loginCheckJs",R.string.login_check_js),f("coverDecodeJs",R.string.cover_decode_js),f("header",R.string.source_http_header),f("variableComment",R.string.variable_comment),f("concurrentRate",R.string.concurrent_rate),F("jsLib",l="jsLib")),
+        RssSourceEditTab.Start to listOf(f("startHtml",R.string.r_startHtml),f("startStyle",R.string.r_startStyle),f("startJs",R.string.r_startJs),f("preloadJs",R.string.r_preloadJs)),
+        RssSourceEditTab.List to listOf(f("ruleArticles",R.string.r_articles),f("ruleNextPage",R.string.r_next),f("ruleTitle",R.string.r_title),f("rulePubDate",R.string.r_date),f("ruleDescription",R.string.r_description),f("ruleImage",R.string.r_image),f("ruleLink",R.string.r_link)),
+        RssSourceEditTab.WebView to listOf(f("ruleContent",R.string.r_content),f("style",R.string.r_style),f("injectJs",R.string.r_inject_js),f("contentWhitelist",R.string.c_whitelist),f("contentBlacklist",R.string.c_blacklist),F("shouldOverrideUrlLoading",l="URL 跳转拦截 JS"))) }
+}

+ 12 - 24
app/src/main/java/io/legado/app/web/socket/BookSourceDebugWebSocket.kt

@@ -13,10 +13,9 @@ import splitties.init.appCtx
  * web端书源调试
  */
 class BookSourceDebugWebSocket(private val session: DefaultWebSocketServerSession) :
-    CoroutineScope by session,
-    Debug.Callback {
+    CoroutineScope by session {
 
-    private val notPrintState = arrayOf(10, 20, 30, 40)
+    private var debugSession: Debug.Session? = null
 
     suspend fun handle() {
         try {
@@ -38,8 +37,14 @@ class BookSourceDebugWebSocket(private val session: DefaultWebSocketServerSessio
                             break
                         }
                         appDb.bookSourceDao.getBookSource(tag)?.let {
-                            Debug.callback = this@BookSourceDebugWebSocket
-                            Debug.startDebug(this, it, key)
+                            val current = Debug.startDebug(this, it, key)
+                            debugSession = current
+                            current.events.collect { event ->
+                                if (!event.kind.isSourcePayload) session.send(event.message)
+                                if (event.kind.isTerminal) {
+                                    session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
+                                }
+                            }
                         }
                     } else {
                         session.send("数据必须为Json格式")
@@ -51,25 +56,8 @@ class BookSourceDebugWebSocket(private val session: DefaultWebSocketServerSessio
         } catch (e: Exception) {
             e.printOnDebug()
         } finally {
-            Debug.cancelDebug(true)
+            debugSession?.cancel()
+            debugSession = null
         }
     }
-
-    override fun printLog(state: Int, msg: String) {
-        if (state in notPrintState) {
-            return
-        }
-        launch(Dispatchers.IO) {
-            runCatching {
-                session.send(msg)
-                if (state == -1 || state == 1000) {
-                    Debug.cancelDebug(true)
-                    session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
-                }
-            }.onFailure {
-                it.printOnDebug()
-            }
-        }
-    }
-
 }

+ 12 - 23
app/src/main/java/io/legado/app/web/socket/RssSourceDebugWebSocket.kt

@@ -13,10 +13,9 @@ import splitties.init.appCtx
  * web端订阅源调试
  */
 class RssSourceDebugWebSocket(private val session: DefaultWebSocketServerSession) :
-    CoroutineScope by session,
-    Debug.Callback {
+    CoroutineScope by session {
 
-    private val notPrintState = arrayOf(10, 20, 30, 40)
+    private var debugSession: Debug.Session? = null
 
     suspend fun handle() {
         try {
@@ -37,8 +36,14 @@ class RssSourceDebugWebSocket(private val session: DefaultWebSocketServerSession
                             break
                         }
                         appDb.rssSourceDao.getByKey(tag)?.let {
-                            Debug.callback = this@RssSourceDebugWebSocket
-                            Debug.startDebug(this, it)
+                            val current = Debug.startDebug(this, it)
+                            debugSession = current
+                            current.events.collect { event ->
+                                if (!event.kind.isSourcePayload) session.send(event.message)
+                                if (event.kind.isTerminal) {
+                                    session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
+                                }
+                            }
                         }
                     }
                 }
@@ -46,24 +51,8 @@ class RssSourceDebugWebSocket(private val session: DefaultWebSocketServerSession
         } catch (e: Exception) {
             e.printOnDebug()
         } finally {
-            Debug.cancelDebug(true)
-        }
-    }
-
-    override fun printLog(state: Int, msg: String) {
-        if (state in notPrintState) {
-            return
-        }
-        launch(Dispatchers.IO) {
-            runCatching {
-                session.send(msg)
-                if (state == -1 || state == 1000) {
-                    Debug.cancelDebug(true)
-                    session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
-                }
-            }.onFailure {
-                it.printOnDebug()
-            }
+            debugSession?.cancel()
+            debugSession = null
         }
     }
 }

+ 0 - 129
app/src/main/res/layout/activity_rss_source_edit.xml

@@ -1,129 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <io.legado.app.ui.widget.TitleBar
-        android:id="@+id/title_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        app:contentInsetStartWithNavigation="0dp"
-        app:displayHomeAsUp="true"
-        app:title="@string/rss_source_edit" />
-
-    <HorizontalScrollView
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:scrollbars="none">
-
-        <LinearLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center_vertical"
-            android:orientation="horizontal"
-            android:paddingHorizontal="12dp"
-            tools:ignore="VisualLintBounds">
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:checked="true"
-                android:text="@string/is_enable" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_single_url"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:checked="false"
-                android:text="@string/single_url" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable_cookie"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:checked="true"
-                android:text="@string/auto_save_cookie" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable_preload"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:checked="false"
-                android:text="@string/enable_preload" />
-
-        </LinearLayout>
-
-    </HorizontalScrollView>
-
-    <HorizontalScrollView
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:scrollbars="none">
-        <LinearLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center_vertical"
-            android:orientation="horizontal"
-            android:paddingHorizontal="12dp"
-            tools:ignore="VisualLintBounds">
-
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="match_parent"
-                android:gravity="center"
-                android:labelFor="@id/sp_type"
-                android:text="@string/book_type"
-                tools:ignore="RtlHardcoded" />
-
-            <androidx.appcompat.widget.AppCompatSpinner
-                android:id="@+id/sp_type"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:entries="@array/rss_type"
-                android:theme="@style/Spinner" />
-
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="match_parent"
-                android:gravity="center"
-                android:labelFor="@id/ly_type"
-                android:text="@string/layout_type"
-                tools:ignore="RtlHardcoded,RtlSymmetry" />
-
-            <androidx.appcompat.widget.AppCompatSpinner
-                android:id="@+id/ly_type"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:entries="@array/layout_type"
-                android:theme="@style/Spinner" />
-
-        </LinearLayout>
-
-    </HorizontalScrollView>
-
-    <com.google.android.material.tabs.TabLayout
-        android:id="@+id/tab_layout"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:paddingHorizontal="12dp"
-        tools:ignore="SpeakableTextPresentCheck" />
-
-    <androidx.recyclerview.widget.RecyclerView
-        android:id="@+id/recycler_view"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:clipToPadding="false"
-        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
-        tools:listitem="@layout/item_source_edit" />
-
-</LinearLayout>

+ 0 - 196
app/src/main/res/layout/activity_source_debug.xml

@@ -1,196 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/ll_content"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <io.legado.app.ui.widget.TitleBar
-        android:id="@+id/title_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        app:contentInsetStartWithNavigation="0dp"
-        app:contentLayout="@layout/view_search"
-        app:layout_constraintTop_toTopOf="parent"
-        app:title="@string/debug_source" />
-
-    <androidx.recyclerview.widget.RecyclerView
-        android:id="@+id/recycler_view"
-        android:layout_width="match_parent"
-        android:layout_height="0dp"
-        android:clipToPadding="false"
-        app:layoutManager="io.legado.app.ui.widget.recycler.NoChildScrollLinearLayoutManager"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/title_bar" />
-
-    <com.google.android.material.loadingindicator.LoadingIndicator
-        android:id="@+id/rotate_loading"
-        android:layout_width="36dp"
-        android:layout_height="36dp"
-        android:layout_margin="6dp"
-        android:contentDescription="@string/loading"
-        android:visibility="gone"
-        app:layout_constraintLeft_toLeftOf="parent"
-        app:layout_constraintRight_toRightOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/title_bar"
-        app:loading_width="2dp" />
-
-    <io.legado.app.ui.widget.NoChildScrollNestedScrollView
-        android:id="@+id/help"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:visibility="gone"
-        app:layout_constraintTop_toBottomOf="@+id/title_bar">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="vertical"
-            android:padding="16dp">
-
-            <TextView
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="调试搜索 - 输入关键字,如:"
-                tools:ignore="HardcodedText" />
-
-            <LinearLayout
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:orientation="horizontal">
-
-                <TextView
-                    android:id="@+id/text_my"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:layout_margin="3dp"
-                    android:background="@drawable/selector_fillet_btn_bg"
-                    android:ellipsize="end"
-                    android:gravity="center"
-                    android:maxLines="1"
-                    android:paddingLeft="12dp"
-                    android:paddingTop="4dp"
-                    android:paddingRight="12dp"
-                    android:paddingBottom="4dp"
-                    android:text="我的"
-                    android:textSize="14sp"
-                    tools:ignore="HardcodedText,UnusedAttribute" />
-
-                <TextView
-                    android:id="@+id/text_xt"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:layout_margin="3dp"
-                    android:background="@drawable/selector_fillet_btn_bg"
-                    android:ellipsize="end"
-                    android:gravity="center"
-                    android:maxLines="1"
-                    android:paddingLeft="12dp"
-                    android:paddingTop="4dp"
-                    android:paddingRight="12dp"
-                    android:paddingBottom="4dp"
-                    android:text="系统"
-                    android:textSize="14sp"
-                    tools:ignore="HardcodedText,UnusedAttribute" />
-            </LinearLayout>
-
-            <TextView
-                android:id="@+id/text_fx_title"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="调试发现 - 输入发现URL,如:"
-                tools:ignore="HardcodedText" />
-
-            <TextView
-                android:id="@+id/text_fx"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_margin="3dp"
-                android:background="@drawable/selector_fillet_btn_bg"
-                android:ellipsize="end"
-                android:gravity="center"
-                android:maxLines="1"
-                android:paddingLeft="12dp"
-                android:paddingTop="4dp"
-                android:paddingRight="12dp"
-                android:paddingBottom="4dp"
-                android:text="系统::http://xxx"
-                android:textSize="14sp"
-                tools:ignore="HardcodedText,UnusedAttribute" />
-
-            <TextView
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="调试详情页 - 输入详情页URL,如:"
-                tools:ignore="HardcodedText" />
-
-            <TextView
-                android:id="@+id/text_info"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_margin="3dp"
-                android:background="@drawable/selector_fillet_btn_bg"
-                android:ellipsize="end"
-                android:gravity="center"
-                android:maxLines="1"
-                android:paddingLeft="12dp"
-                android:paddingTop="4dp"
-                android:paddingRight="12dp"
-                android:paddingBottom="4dp"
-                android:text="https://m.qidian.com/book/1015609210"
-                android:textSize="14sp"
-                tools:ignore="HardcodedText,UnusedAttribute" />
-
-            <TextView
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="调试目录页 - 输入目录页URL,如:"
-                tools:ignore="HardcodedText" />
-
-            <TextView
-                android:id="@+id/text_toc"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_margin="3dp"
-                android:background="@drawable/selector_fillet_btn_bg"
-                android:ellipsize="end"
-                android:gravity="center"
-                android:maxLines="1"
-                android:paddingLeft="12dp"
-                android:paddingTop="4dp"
-                android:paddingRight="12dp"
-                android:paddingBottom="4dp"
-                android:text="++https://www.zhaishuyuan.com/read/30394"
-                android:textSize="14sp"
-                tools:ignore="HardcodedText,UnusedAttribute" />
-
-            <TextView
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="调试正文页 - 输入正文页URL,如:"
-                tools:ignore="HardcodedText" />
-
-            <TextView
-                android:id="@+id/text_content"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_margin="3dp"
-                android:background="@drawable/selector_fillet_btn_bg"
-                android:ellipsize="end"
-                android:gravity="center"
-                android:maxLines="1"
-                android:paddingLeft="12dp"
-                android:paddingTop="4dp"
-                android:paddingRight="12dp"
-                android:paddingBottom="4dp"
-                android:text="--https://www.zhaishuyuan.com/chapter/30394/20940996"
-                android:textSize="14sp"
-                tools:ignore="HardcodedText,UnusedAttribute" />
-
-        </LinearLayout>
-
-    </io.legado.app.ui.widget.NoChildScrollNestedScrollView>
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 43
app/src/main/res/menu/book_source_debug.xml

@@ -1,43 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    tools:ignore="AlwaysShowAction">
-
-    <item
-        android:id="@+id/menu_scan"
-        android:icon="@drawable/ic_scan"
-        android:title="@string/scan_qr_code"
-        app:showAsAction="always" />
-
-    <item
-        android:id="@+id/menu_search_src"
-        android:title="@string/search_src"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_book_src"
-        android:title="@string/boo_src"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_toc_src"
-        android:title="@string/toc_src"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_content_src"
-        android:title="@string/content_src"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_refresh_explore"
-        android:title="@string/refresh_explore"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_help"
-        android:title="@string/help"
-        app:showAsAction="never" />
-
-</menu>

+ 0 - 17
app/src/main/res/menu/rss_source_debug.xml

@@ -1,17 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    tools:ignore="AlwaysShowAction">
-
-    <item
-        android:id="@+id/menu_list_src"
-        android:title="@string/list_src"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_content_src"
-        android:title="@string/content_src"
-        app:showAsAction="never" />
-
-</menu>

+ 1 - 1
app/src/main/res/values-zh-rCN/strings.xml

@@ -709,7 +709,7 @@
     <string name="refresh_cover">刷新封面</string>
     <string name="change_cover_source">封面换源</string>
     <string name="select_local_image">选择本地图片</string>
-    <string name="book_type">类型 :</string>
+    <string name="book_type">"类型 "</string>
     <string name="to_backstage">后台</string>
     <string name="importing">正在导入</string>
     <string name="exporting">正在导出</string>

+ 1 - 1
app/src/main/res/values-zh-rHK/strings.xml

@@ -638,7 +638,7 @@
     <string name="refresh_cover">刷新封面</string>
     <string name="change_cover_source">封面換源</string>
     <string name="select_local_image">選擇本地圖片</string>
-    <string name="book_type">類型: </string>
+    <string name="book_type">類型</string>
     <string name="to_backstage">後台</string>
     <string name="importing">正在導入</string>
     <string name="exporting">正在導出</string>

+ 1 - 1
app/src/main/res/values-zh-rTW/strings.xml

@@ -647,7 +647,7 @@
     <string name="refresh_cover">重新整理封面</string>
     <string name="change_cover_source">封面換源</string>
     <string name="select_local_image">選擇本機圖片</string>
-    <string name="book_type">類型:</string>
+    <string name="book_type">類型</string>
     <string name="to_backstage">後台</string>
     <string name="importing">正在匯入</string>
     <string name="exporting">正在匯出</string>

+ 2 - 1
app/src/main/res/values/strings.xml

@@ -744,7 +744,7 @@
     <string name="refresh_cover">Refresh cover</string>
     <string name="change_cover_source">Change origin</string>
     <string name="select_local_image">Local image</string>
-    <string name="book_type">Type:</string>
+    <string name="book_type">Type</string>
     <string name="to_backstage">Background</string>
     <string name="importing">Importing</string>
     <string name="exporting">Exporting</string>
@@ -1350,6 +1350,7 @@
     <string name="clear_webview_data">Clear WebView Data</string>
     <string name="clear_webview_data_summary">Clear all data in WebView</string>
     <string name="source_tab_list">List</string>
+    <string name="source_tab_web_view">Web view</string>
     <string name="source_tab_start">Startup</string>
     <string name="r_startHtml">Startup page HTML text</string>
     <string name="r_startStyle">Startup page style text</string>