diff --git a/app/src/main/assets/help/appHelp.md b/app/src/main/assets/help/appHelp.md index 78dd3e976..c58c91b55 100644 --- a/app/src/main/assets/help/appHelp.md +++ b/app/src/main/assets/help/appHelp.md @@ -5,7 +5,7 @@ 【温馨提醒】 *本帮助可以在我的-右上角帮助按钮再次打开,更新前一定要做好备份,以免数据丢失!* 1. 为什么第一次安装好之后什么东西都没有? -* 阅读只是一个转码工具,不提供内容,第一次安装app,需要自己手动导入书源,可以从公众号[开源阅读]()、QQ群、酷安评论里获取由书友制作分享的书源。 +* 阅读只是一个转码工具,不提供内容,第一次安装app,需要自己手动导入书源,可以从公众号**[开源阅读]**、QQ群、酷安评论里获取由书友制作分享的书源。 2. 正文出现缺字漏字、内容缺失、排版错乱等情况,如何处理? * 有可能是净化规则出现问题,先关闭替换净化并刷新,再观察是否正常。如果正常说明净化规则存在误杀,如果关闭后仍然出现相关问题,请点击源链接查看原文与正文是否相同,如果不同,再进行反馈。 diff --git a/app/src/main/assets/help/sourceHelp.md b/app/src/main/assets/help/sourceHelp.md new file mode 100644 index 000000000..e4453db33 --- /dev/null +++ b/app/src/main/assets/help/sourceHelp.md @@ -0,0 +1,5 @@ +# 源规则帮助 + +* [书源帮助文档](https://alanskycn.gitee.io/teachme/Rule/source.html) +* [订阅源帮助文档](https://alanskycn.gitee.io/teachme/Rule/rss.html) + diff --git a/app/src/main/assets/updateLog.md b/app/src/main/assets/updateLog.md index da8aa319c..945e27da4 100644 --- a/app/src/main/assets/updateLog.md +++ b/app/src/main/assets/updateLog.md @@ -1,6 +1,6 @@ # 更新日志 -* 关注公众号 **[开源阅读]()** 菜单•软件下载 提前享受新版本。 -* 关注合作公众号 **[小说拾遗]()** 获取好看的小说。 +* 关注公众号 **[开源阅读]** 菜单•软件下载 提前享受新版本。 +* 关注合作公众号 **[小说拾遗]** 获取好看的小说。 * 旧版数据导入教程:先在旧版阅读(2.x)中进行备份,然后在新版阅读(3.x)【我的】->【备份与恢复】,选择【导入旧版本数据】。 **2020/11/18** diff --git a/app/src/main/java/io/legado/app/model/webBook/BookContent.kt b/app/src/main/java/io/legado/app/model/webBook/BookContent.kt index 3c68bece1..760e8efd1 100644 --- a/app/src/main/java/io/legado/app/model/webBook/BookContent.kt +++ b/app/src/main/java/io/legado/app/model/webBook/BookContent.kt @@ -13,7 +13,6 @@ import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.QueryTTF import io.legado.app.utils.NetworkUtils import io.legado.app.utils.htmlFormat -import io.legado.app.utils.toStringArray import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext diff --git a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt index fa4a12f0a..6a90c89f7 100644 --- a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt @@ -390,13 +390,18 @@ class BookSourceEditActivity : selector(getString(R.string.help), items) { _, index -> when (index) { 0 -> insertText(AppConst.urlOption) - 1 -> openUrl("https://alanskycn.gitee.io/teachme/Rule/source.html") + 1 -> showSourceHelp() 2 -> showRegexHelp() 3 -> FilePicker.selectFile(this, selectPathRequestCode) } } } + private fun showSourceHelp() { + val mdText = String(assets.open("help/sourceHelp.md").readBytes()) + TextDialog.show(supportFragmentManager, mdText, TextDialog.MD) + } + private fun showRegexHelp() { val mdText = String(assets.open("help/regexHelp.md").readBytes()) TextDialog.show(supportFragmentManager, mdText, TextDialog.MD) diff --git a/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt index aa7a2db15..dbcc4b0b1 100644 --- a/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt +++ b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt @@ -21,7 +21,10 @@ import io.legado.app.ui.qrcode.QrCodeActivity import io.legado.app.ui.rss.source.debug.RssSourceDebugActivity import io.legado.app.ui.widget.KeyboardToolPop import io.legado.app.ui.widget.dialog.TextDialog -import io.legado.app.utils.* +import io.legado.app.utils.GSON +import io.legado.app.utils.getViewModel +import io.legado.app.utils.sendToClip +import io.legado.app.utils.shareWithQr import kotlinx.android.synthetic.main.activity_rss_source_edit.* import org.jetbrains.anko.* import kotlin.math.abs @@ -199,12 +202,17 @@ class RssSourceEditActivity : selector(getString(R.string.help), items) { _, index -> when (index) { 0 -> insertText(AppConst.urlOption) - 1 -> openUrl("https://alanskycn.gitee.io/teachme/Rule/rss.html") + 1 -> showSourceHelp() 2 -> showRegexHelp() } } } + private fun showSourceHelp() { + val mdText = String(assets.open("help/sourceHelp.md").readBytes()) + TextDialog.show(supportFragmentManager, mdText, TextDialog.MD) + } + private fun showRegexHelp() { val mdText = String(assets.open("help/regexHelp.md").readBytes()) TextDialog.show(supportFragmentManager, mdText, TextDialog.MD) diff --git a/app/src/main/java/io/legado/app/ui/widget/text/BetterLinkMovementMethod.kt b/app/src/main/java/io/legado/app/ui/widget/text/BetterLinkMovementMethod.kt new file mode 100644 index 000000000..da4ea65eb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/text/BetterLinkMovementMethod.kt @@ -0,0 +1,437 @@ +package io.legado.app.ui.widget.text + +import android.app.Activity +import android.graphics.RectF +import android.text.Selection +import android.text.Spannable +import android.text.Spanned +import android.text.method.LinkMovementMethod +import android.text.style.BackgroundColorSpan +import android.text.style.ClickableSpan +import android.text.style.URLSpan +import android.text.util.Linkify +import android.view.* +import android.widget.TextView +import io.legado.app.R +import io.legado.app.ui.widget.text.BetterLinkMovementMethod.LongPressTimer.OnTimerReachedListener + +class BetterLinkMovementMethod protected constructor() : LinkMovementMethod() { + private var onLinkClickListener: OnLinkClickListener? = null + private var onLinkLongClickListener: OnLinkLongClickListener? = null + private val touchedLineBounds = RectF() + private var isUrlHighlighted = false + private var clickableSpanUnderTouchOnActionDown: ClickableSpan? = null + private var activeTextViewHashcode = 0 + private var ongoingLongPressTimer: LongPressTimer? = null + private var wasLongPressRegistered = false + + interface OnLinkClickListener { + /** + * @param textView The TextView on which a click was registered. + * @param url The clicked URL. + * @return True if this click was handled. False to let Android handle the URL. + */ + fun onClick(textView: TextView?, url: String?): Boolean + } + + interface OnLinkLongClickListener { + /** + * @param textView The TextView on which a long-click was registered. + * @param url The long-clicked URL. + * @return True if this long-click was handled. False to let Android handle the URL (as a short-click). + */ + fun onLongClick(textView: TextView?, url: String?): Boolean + } + + /** + * Set a listener that will get called whenever any link is clicked on the TextView. + */ + fun setOnLinkClickListener(clickListener: OnLinkClickListener?): BetterLinkMovementMethod { + if (this === singleInstance) { + throw UnsupportedOperationException( + "Setting a click listener on the instance returned by getInstance() is not supported to avoid memory " + + "leaks. Please use newInstance() or any of the linkify() methods instead." + ) + } + onLinkClickListener = clickListener + return this + } + + /** + * Set a listener that will get called whenever any link is clicked on the TextView. + */ + fun setOnLinkLongClickListener(longClickListener: OnLinkLongClickListener?): BetterLinkMovementMethod { + if (this === singleInstance) { + throw UnsupportedOperationException( + "Setting a long-click listener on the instance returned by getInstance() is not supported to avoid " + + "memory leaks. Please use newInstance() or any of the linkify() methods instead." + ) + } + onLinkLongClickListener = longClickListener + return this + } + + override fun onTouchEvent(textView: TextView, text: Spannable, event: MotionEvent): Boolean { + if (activeTextViewHashcode != textView.hashCode()) { + // Bug workaround: TextView stops calling onTouchEvent() once any URL is highlighted. + // A hacky solution is to reset any "autoLink" property set in XML. But we also want + // to do this once per TextView. + activeTextViewHashcode = textView.hashCode() + textView.autoLinkMask = 0 + } + val clickableSpanUnderTouch = findClickableSpanUnderTouch(textView, text, event) + if (event.action == MotionEvent.ACTION_DOWN) { + clickableSpanUnderTouchOnActionDown = clickableSpanUnderTouch + } + val touchStartedOverAClickableSpan = clickableSpanUnderTouchOnActionDown != null + return when (event.action) { + MotionEvent.ACTION_DOWN -> { + clickableSpanUnderTouch?.let { highlightUrl(textView, it, text) } + if (touchStartedOverAClickableSpan && onLinkLongClickListener != null) { + val longClickListener: OnTimerReachedListener = + object : OnTimerReachedListener { + override fun onTimerReached() { + wasLongPressRegistered = true + textView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + removeUrlHighlightColor(textView) + dispatchUrlLongClick(textView, clickableSpanUnderTouch) + } + } + startTimerForRegisteringLongClick(textView, longClickListener) + } + touchStartedOverAClickableSpan + } + MotionEvent.ACTION_UP -> { + // Register a click only if the touch started and ended on the same URL. + if (!wasLongPressRegistered && touchStartedOverAClickableSpan && clickableSpanUnderTouch === clickableSpanUnderTouchOnActionDown) { + dispatchUrlClick(textView, clickableSpanUnderTouch) + } + cleanupOnTouchUp(textView) + + // Consume this event even if we could not find any spans to avoid letting Android handle this event. + // Android's TextView implementation has a bug where links get clicked even when there is no more text + // next to the link and the touch lies outside its bounds in the same direction. + touchStartedOverAClickableSpan + } + MotionEvent.ACTION_CANCEL -> { + cleanupOnTouchUp(textView) + false + } + MotionEvent.ACTION_MOVE -> { + // Stop listening for a long-press as soon as the user wanders off to unknown lands. + if (clickableSpanUnderTouch !== clickableSpanUnderTouchOnActionDown) { + removeLongPressCallback(textView) + } + if (!wasLongPressRegistered) { + // Toggle highlight. + if (clickableSpanUnderTouch != null) { + highlightUrl(textView, clickableSpanUnderTouch, text) + } else { + removeUrlHighlightColor(textView) + } + } + touchStartedOverAClickableSpan + } + else -> false + } + } + + private fun cleanupOnTouchUp(textView: TextView) { + wasLongPressRegistered = false + clickableSpanUnderTouchOnActionDown = null + removeUrlHighlightColor(textView) + removeLongPressCallback(textView) + } + + /** + * Determines the touched location inside the TextView's text and returns the ClickableSpan found under it (if any). + * + * @return The touched ClickableSpan or null. + */ + protected fun findClickableSpanUnderTouch( + textView: TextView, + text: Spannable, + event: MotionEvent + ): ClickableSpan? { + // So we need to find the location in text where touch was made, regardless of whether the TextView + // has scrollable text. That is, not the entire text is currently visible. + var touchX = event.x.toInt() + var touchY = event.y.toInt() + + // Ignore padding. + touchX -= textView.totalPaddingLeft + touchY -= textView.totalPaddingTop + + // Account for scrollable text. + touchX += textView.scrollX + touchY += textView.scrollY + val layout = textView.layout + val touchedLine = layout.getLineForVertical(touchY) + val touchOffset = layout.getOffsetForHorizontal(touchedLine, touchX.toFloat()) + touchedLineBounds.left = layout.getLineLeft(touchedLine) + touchedLineBounds.top = layout.getLineTop(touchedLine).toFloat() + touchedLineBounds.right = layout.getLineWidth(touchedLine) + touchedLineBounds.left + touchedLineBounds.bottom = layout.getLineBottom(touchedLine).toFloat() + return if (touchedLineBounds.contains(touchX.toFloat(), touchY.toFloat())) { + // Find a ClickableSpan that lies under the touched area. + val spans = text.getSpans(touchOffset, touchOffset, ClickableSpan::class.java) + for (span in spans) { + if (span is ClickableSpan) { + return span + } + } + // No ClickableSpan found under the touched location. + null + } else { + // Touch lies outside the line's horizontal bounds where no spans should exist. + null + } + } + + /** + * Adds a background color span at clickableSpan's location. + */ + protected fun highlightUrl(textView: TextView, clickableSpan: ClickableSpan?, text: Spannable) { + if (isUrlHighlighted) { + return + } + isUrlHighlighted = true + val spanStart = text.getSpanStart(clickableSpan) + val spanEnd = text.getSpanEnd(clickableSpan) + val highlightSpan = BackgroundColorSpan(textView.highlightColor) + text.setSpan(highlightSpan, spanStart, spanEnd, Spannable.SPAN_INCLUSIVE_INCLUSIVE) + textView.setTag(R.id.bettermovementmethod_highlight_background_span, highlightSpan) + Selection.setSelection(text, spanStart, spanEnd) + } + + /** + * Removes the highlight color under the Url. + */ + protected fun removeUrlHighlightColor(textView: TextView) { + if (!isUrlHighlighted) { + return + } + isUrlHighlighted = false + val text = textView.text as Spannable + val highlightSpan = + textView.getTag(R.id.bettermovementmethod_highlight_background_span) as BackgroundColorSpan + text.removeSpan(highlightSpan) + Selection.removeSelection(text) + } + + protected fun startTimerForRegisteringLongClick( + textView: TextView, + longClickListener: OnTimerReachedListener? + ) { + ongoingLongPressTimer = LongPressTimer() + ongoingLongPressTimer!!.setOnTimerReachedListener(longClickListener) + textView.postDelayed( + ongoingLongPressTimer, + ViewConfiguration.getLongPressTimeout().toLong() + ) + } + + /** + * Remove the long-press detection timer. + */ + protected fun removeLongPressCallback(textView: TextView) { + if (ongoingLongPressTimer != null) { + textView.removeCallbacks(ongoingLongPressTimer) + ongoingLongPressTimer = null + } + } + + protected fun dispatchUrlClick(textView: TextView, clickableSpan: ClickableSpan?) { + val clickableSpanWithText = ClickableSpanWithText.ofSpan(textView, clickableSpan) + val handled = onLinkClickListener != null && onLinkClickListener!!.onClick( + textView, + clickableSpanWithText.text() + ) + if (!handled) { + // Let Android handle this click. + clickableSpanWithText.span()!!.onClick(textView) + } + } + + protected fun dispatchUrlLongClick(textView: TextView, clickableSpan: ClickableSpan?) { + val clickableSpanWithText = ClickableSpanWithText.ofSpan(textView, clickableSpan) + val handled = onLinkLongClickListener != null && onLinkLongClickListener!!.onLongClick( + textView, + clickableSpanWithText.text() + ) + if (!handled) { + // Let Android handle this long click as a short-click. + clickableSpanWithText.span()!!.onClick(textView) + } + } + + protected class LongPressTimer : Runnable { + private var onTimerReachedListener: OnTimerReachedListener? = null + + interface OnTimerReachedListener { + fun onTimerReached() + } + + override fun run() { + onTimerReachedListener!!.onTimerReached() + } + + fun setOnTimerReachedListener(listener: OnTimerReachedListener?) { + onTimerReachedListener = listener + } + } + + /** + * A wrapper to support all [ClickableSpan]s that may or may not provide URLs. + */ + protected class ClickableSpanWithText protected constructor( + private val span: ClickableSpan?, + private val text: String + ) { + fun span(): ClickableSpan? { + return span + } + + fun text(): String { + return text + } + + companion object { + fun ofSpan(textView: TextView, span: ClickableSpan?): ClickableSpanWithText { + val s = textView.text as Spanned + val text: String + text = if (span is URLSpan) { + span.url + } else { + val start = s.getSpanStart(span) + val end = s.getSpanEnd(span) + s.subSequence(start, end).toString() + } + return ClickableSpanWithText(span, text) + } + } + } + + companion object { + private var singleInstance: BetterLinkMovementMethod? = null + private const val LINKIFY_NONE = -2 + + /** + * Return a new instance of BetterLinkMovementMethod. + */ + fun newInstance(): BetterLinkMovementMethod { + return BetterLinkMovementMethod() + } + + /** + * @param linkifyMask One of [Linkify.ALL], [Linkify.PHONE_NUMBERS], [Linkify.MAP_ADDRESSES], + * [Linkify.WEB_URLS] and [Linkify.EMAIL_ADDRESSES]. + * @param textViews The TextViews on which a [BetterLinkMovementMethod] should be registered. + * @return The registered [BetterLinkMovementMethod] on the TextViews. + */ + fun linkify(linkifyMask: Int, vararg textViews: TextView): BetterLinkMovementMethod { + val movementMethod = newInstance() + for (textView in textViews) { + addLinks(linkifyMask, movementMethod, textView) + } + return movementMethod + } + + /** + * Like [.linkify], but can be used for TextViews with HTML links. + * + * @param textViews The TextViews on which a [BetterLinkMovementMethod] should be registered. + * @return The registered [BetterLinkMovementMethod] on the TextViews. + */ + fun linkifyHtml(vararg textViews: TextView): BetterLinkMovementMethod { + return linkify(LINKIFY_NONE, *textViews) + } + + /** + * Recursively register a [BetterLinkMovementMethod] on every TextView inside a layout. + * + * @param linkifyMask One of [Linkify.ALL], [Linkify.PHONE_NUMBERS], [Linkify.MAP_ADDRESSES], + * [Linkify.WEB_URLS] and [Linkify.EMAIL_ADDRESSES]. + * @return The registered [BetterLinkMovementMethod] on the TextViews. + */ + fun linkify(linkifyMask: Int, viewGroup: ViewGroup): BetterLinkMovementMethod { + val movementMethod = newInstance() + rAddLinks(linkifyMask, viewGroup, movementMethod) + return movementMethod + } + + /** + * Like [.linkify], but can be used for TextViews with HTML links. + * + * @return The registered [BetterLinkMovementMethod] on the TextViews. + */ + fun linkifyHtml(viewGroup: ViewGroup): BetterLinkMovementMethod { + return linkify(LINKIFY_NONE, viewGroup) + } + + /** + * Recursively register a [BetterLinkMovementMethod] on every TextView inside a layout. + * + * @param linkifyMask One of [Linkify.ALL], [Linkify.PHONE_NUMBERS], [Linkify.MAP_ADDRESSES], + * [Linkify.WEB_URLS] and [Linkify.EMAIL_ADDRESSES]. + * @return The registered [BetterLinkMovementMethod] on the TextViews. + */ + fun linkify(linkifyMask: Int, activity: Activity): BetterLinkMovementMethod { + // Find the layout passed to setContentView(). + val activityLayout = + (activity.findViewById(Window.ID_ANDROID_CONTENT) as ViewGroup).getChildAt(0) as ViewGroup + val movementMethod = newInstance() + rAddLinks(linkifyMask, activityLayout, movementMethod) + return movementMethod + } + + /** + * Like [.linkify], but can be used for TextViews with HTML links. + * + * @return The registered [BetterLinkMovementMethod] on the TextViews. + */ + fun linkifyHtml(activity: Activity): BetterLinkMovementMethod { + return linkify(LINKIFY_NONE, activity) + } + + /** + * Get a static instance of BetterLinkMovementMethod. Do note that registering a click listener on the returned + * instance is not supported because it will potentially be shared on multiple TextViews. + */ + val instance: BetterLinkMovementMethod? + get() { + if (singleInstance == null) { + singleInstance = BetterLinkMovementMethod() + } + return singleInstance + } + + // ======== PUBLIC APIs END ======== // + private fun rAddLinks( + linkifyMask: Int, + viewGroup: ViewGroup, + movementMethod: BetterLinkMovementMethod + ) { + for (i in 0 until viewGroup.childCount) { + val child = viewGroup.getChildAt(i) + if (child is ViewGroup) { + // Recursively find child TextViews. + rAddLinks(linkifyMask, child, movementMethod) + } else if (child is TextView) { + addLinks(linkifyMask, movementMethod, child) + } + } + } + + private fun addLinks( + linkifyMask: Int, + movementMethod: BetterLinkMovementMethod, + textView: TextView + ) { + textView.movementMethod = movementMethod + if (linkifyMask != LINKIFY_NONE) { + Linkify.addLinks(textView, linkifyMask) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/text/InertiaScrollTextView.kt b/app/src/main/java/io/legado/app/ui/widget/text/InertiaScrollTextView.kt index 9644b8676..d5a376893 100644 --- a/app/src/main/java/io/legado/app/ui/widget/text/InertiaScrollTextView.kt +++ b/app/src/main/java/io/legado/app/ui/widget/text/InertiaScrollTextView.kt @@ -48,6 +48,7 @@ open class InertiaScrollTextView @JvmOverloads constructor( mTouchSlop = vc.scaledTouchSlop mMinFlingVelocity = vc.scaledMinimumFlingVelocity mMaxFlingVelocity = vc.scaledMaximumFlingVelocity + movementMethod = BetterLinkMovementMethod.instance } fun atTop(): Boolean { diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index cc2d9c170..c80e30395 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -5,4 +5,7 @@ + + + \ No newline at end of file