Fix Markdown rendering

Close #3074
This commit is contained in:
topjohnwu 2020-08-14 02:00:06 -07:00
parent d8b1d79879
commit ac2a9da4c4
9 changed files with 371 additions and 84 deletions

View File

@ -0,0 +1,244 @@
package com.topjohnwu.magisk.core.utils
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.PictureDrawable
import android.graphics.drawable.ShapeDrawable
import android.net.Uri
import android.text.Spanned
import android.text.style.DynamicDrawableSpan
import android.view.View
import android.widget.TextView
import androidx.annotation.WorkerThread
import androidx.core.view.marginLeft
import androidx.core.view.marginRight
import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException
import com.topjohnwu.magisk.core.ResMgr
import com.topjohnwu.superuser.internal.WaitRunnable
import io.noties.markwon.*
import io.noties.markwon.image.*
import io.noties.markwon.image.data.DataUriSchemeHandler
import io.noties.markwon.image.network.OkHttpNetworkSchemeHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.commonmark.node.Image
import timber.log.Timber
import java.io.InputStream
// Differences with Markwon stock ImagePlugin:
//
// We assume beforeSetText() will be run in a background thread, and in that method
// we download/decode all drawables before sending the spanned markdown CharSequence
// to the next stage. We also get our surrounding TextView width to properly
// resize our images.
//
// This is required for PrecomputedText to properly take the images into account
// when precomputing the metrics of TextView
//
// Basically, we want nothing to do with AsyncDrawable
class MarkwonImagePlugin(okHttp: OkHttpClient) : AbstractMarkwonPlugin() {
override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) {
builder.setFactory(Image::class.java, ImageSpanFactory())
}
@WorkerThread
override fun beforeSetText(tv: TextView, markdown: Spanned) {
if (markdown.isEmpty())
return
val spans = markdown.getSpans(0, markdown.length, ImageSpan::class.java)
if (spans == null || spans.isEmpty())
return
// Download and create all drawables in parallel
runBlocking(Dispatchers.IO) {
// Download and decode all drawables in parallel
val defers = spans.map { async { it.load() } }
// Get TextView sizes beforehand to resize all images
// We get its parent here as the TextView itself might be hidden
val parent = tv.parent as View
parent.post(WaitRunnable{
// Make sure it is rendered
val width = parent.width -
tv.paddingLeft - tv.paddingRight -
tv.marginLeft - tv.marginRight
spans.forEach { it.canvasWidth = width }
})
// Make sure all is done before returning
defers.awaitAll()
}
}
private val schemeHandlers = HashMap<String, SchemeHandler>(3)
private val mediaDecoders = HashMap<String, MediaDecoder>(0)
private val defaultMediaDecoder = DefaultMediaDecoder.create()
init {
addSchemeHandler(DataUriSchemeHandler.create())
addSchemeHandler(OkHttpNetworkSchemeHandler.create(okHttp))
addMediaDecoder(SVGDecoder())
}
private fun addSchemeHandler(schemeHandler: SchemeHandler) {
for (scheme in schemeHandler.supportedSchemes()) {
schemeHandlers[scheme] = schemeHandler
}
}
private fun addMediaDecoder(mediaDecoder: MediaDecoder) {
for (type in mediaDecoder.supportedTypes()) {
mediaDecoders[type] = mediaDecoder
}
}
// Modified from AsyncDrawableLoaderImpl.execute(asyncDrawable)
fun loadDrawable(destination: String): Drawable? {
val uri = Uri.parse(destination)
var drawable: Drawable? = null
try {
val scheme = uri.scheme
check(scheme != null && scheme.isNotEmpty()) {
"No scheme is found: $destination"
}
// obtain scheme handler
val schemeHandler = schemeHandlers[scheme]
?: throw IllegalStateException("No scheme-handler is found: $destination")
// handle scheme
val imageItem = schemeHandler.handle(destination, uri)
// if resulting imageItem needs further decoding -> proceed
drawable = if (imageItem.hasDecodingNeeded()) {
val withDecodingNeeded = imageItem.asWithDecodingNeeded
val mediaDecoder = mediaDecoders[withDecodingNeeded.contentType()]
?: defaultMediaDecoder
mediaDecoder.decode(
withDecodingNeeded.contentType(),
withDecodingNeeded.inputStream()
)
} else {
imageItem.asWithResult.result()
}
} catch (t: Throwable) {
Timber.e(t, "Error loading image: $destination")
}
// apply intrinsic bounds (but only if they are empty)
if (drawable != null && drawable.bounds.isEmpty)
DrawableUtils.applyIntrinsicBounds(drawable)
return drawable
}
inner class ImageSpanFactory : SpanFactory {
override fun getSpans(configuration: MarkwonConfiguration, props: RenderProps): Any? {
val dest = ImageProps.DESTINATION.require(props)
val size = ImageProps.IMAGE_SIZE.get(props)
return ImageSpan(dest, size)
}
}
inner class ImageSpan(
private val dest: String,
private val size: ImageSize?
) : DynamicDrawableSpan(ALIGN_BOTTOM) {
var canvasWidth = 0
var measured = false
lateinit var draw: Drawable
fun load() {
draw = loadDrawable(dest) ?: ShapeDrawable()
}
override fun getDrawable() = draw
private fun defaultBounds(): Rect {
val bounds: Rect = draw.bounds
if (!bounds.isEmpty) {
return bounds
}
val intrinsicBounds = DrawableUtils.intrinsicBounds(draw)
if (!intrinsicBounds.isEmpty) {
return intrinsicBounds
}
return Rect(0, 0, 1, 1)
}
private fun measure(paint: Paint) {
if (measured)
return
if (canvasWidth == 0)
return
measured = true
val bound = SizeResolver.resolveImageSize(size, defaultBounds(), canvasWidth, paint.textSize)
draw.bounds = bound
}
override fun getSize(
paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?
): Int {
measure(paint)
return super.getSize(paint, text, start, end, fm)
}
}
object SizeResolver : ImageSizeResolverDef() {
// Expose protected API
public override fun resolveImageSize(
imageSize: ImageSize?,
imageBounds: Rect,
canvasWidth: Int,
textSize: Float
): Rect {
return super.resolveImageSize(imageSize, imageBounds, canvasWidth, textSize)
}
}
class SVGDecoder: MediaDecoder() {
override fun supportedTypes() = listOf("image/svg+xml")
override fun decode(contentType: String?, inputStream: InputStream): Drawable {
val svg: SVG
svg = try {
SVG.getFromInputStream(inputStream)
} catch (e: SVGParseException) {
throw IllegalStateException("Exception decoding SVG", e)
}
val w = svg.documentWidth
val h = svg.documentHeight
if (w == 0f || h == 0f) {
val picture = svg.renderToPicture()
return PictureDrawable(picture)
}
val density: Float = ResMgr.resource.displayMetrics.density
val width = (w * density + .5f).toInt()
val height = (h * density + .5f).toInt()
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.scale(density, density)
svg.renderToCanvas(canvas)
return BitmapDrawable(ResMgr.resource, bitmap)
}
}
}

View File

@ -2,7 +2,6 @@ package com.topjohnwu.magisk.core.utils
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.net.Uri
import android.os.Environment
import android.widget.Toast
@ -24,11 +23,6 @@ object Utils {
UiThreadHandler.run { Toast.makeText(get(), resId, duration).show() }
}
fun dpInPx(dp: Int): Int {
val scale = get<Resources>().displayMetrics.density
return (dp * scale + 0.5).toInt()
}
fun showSuperUser(): Boolean {
return Info.env.isActive && (Const.USER_ID == 0
|| Config.suMultiuserMode != Config.Value.MULTIUSER_MODE_OWNER_MANAGED)

View File

@ -2,15 +2,13 @@ package com.topjohnwu.magisk.databinding
import android.view.View
import android.widget.TextView
import androidx.core.text.PrecomputedTextCompat
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.widget.TextViewCompat
import androidx.databinding.BindingAdapter
import com.topjohnwu.magisk.ktx.coroutineScope
import com.topjohnwu.magisk.ktx.get
import io.noties.markwon.Markwon
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@BindingAdapter("gone")
@ -33,18 +31,10 @@ fun setInvisibleUnless(view: View, invisibleUnless: Boolean) {
setInvisible(view, invisibleUnless.not())
}
@BindingAdapter("precomputedText")
fun setPrecomputedText(tv: TextView, text: CharSequence) {
GlobalScope.launch(Dispatchers.Default) {
val pre = PrecomputedTextCompat.create(text, TextViewCompat.getTextMetricsParams(tv))
tv.post {
TextViewCompat.setPrecomputedText(tv, pre);
}
}
}
@BindingAdapter("markdownText")
fun setMarkdownText(tv: TextView, text: CharSequence) {
val markwon = get<Markwon>()
markwon.setMarkdown(tv, text.toString())
tv.coroutineScope.launch(Dispatchers.IO) {
val markwon = get<Markwon>()
markwon.setMarkdown(tv, text.toString())
}
}

View File

@ -5,15 +5,14 @@ import android.os.Build
import com.squareup.moshi.Moshi
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.utils.MarkwonImagePlugin
import com.topjohnwu.magisk.data.network.GithubApiServices
import com.topjohnwu.magisk.data.network.GithubRawServices
import com.topjohnwu.magisk.ktx.precomputedText
import com.topjohnwu.magisk.net.Networking
import com.topjohnwu.magisk.net.NoSSLv3SocketFactory
import com.topjohnwu.magisk.view.PrecomputedTextSetter
import io.noties.markwon.Markwon
import io.noties.markwon.html.HtmlPlugin
import io.noties.markwon.image.ImagesPlugin
import io.noties.markwon.image.network.OkHttpNetworkSchemeHandler
import okhttp3.Dns
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
@ -96,10 +95,11 @@ inline fun <reified T> createApiService(retrofitBuilder: Retrofit.Builder, baseU
fun createMarkwon(context: Context, okHttpClient: OkHttpClient): Markwon {
return Markwon.builder(context)
.textSetter(PrecomputedTextSetter())
.textSetter { textView, spanned, _, onComplete ->
textView.tag = onComplete
textView.precomputedText = spanned
}
.usePlugin(HtmlPlugin.create())
.usePlugin(ImagesPlugin.create {
it.addSchemeHandler(OkHttpNetworkSchemeHandler.create(okHttpClient))
})
.usePlugin(MarkwonImagePlugin(okHttpClient))
.build()
}

View File

@ -22,8 +22,12 @@ import android.net.Uri
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import android.provider.OpenableColumns
import android.text.PrecomputedText
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.appcompat.content.res.AppCompatResources
@ -31,13 +35,26 @@ import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.core.text.PrecomputedTextCompat
import androidx.core.view.isGone
import androidx.core.widget.TextViewCompat
import androidx.databinding.BindingAdapter
import androidx.fragment.app.Fragment
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
import com.topjohnwu.magisk.FileProvider
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.ResMgr
import com.topjohnwu.magisk.core.utils.Utils
import com.topjohnwu.magisk.core.utils.currentLocale
import com.topjohnwu.magisk.utils.DynamicClassLoader
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileNotFoundException
import java.lang.reflect.Array as JArray
@ -328,3 +345,78 @@ fun Activity.hideKeyboard() {
fun Fragment.hideKeyboard() {
activity?.hideKeyboard()
}
fun View.setOnViewReadyListener(callback: () -> Unit) = addOnGlobalLayoutListener(true, callback)
fun View.addOnGlobalLayoutListener(oneShot: Boolean = false, callback: () -> Unit) =
viewTreeObserver.addOnGlobalLayoutListener(object :
ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if (oneShot) viewTreeObserver.removeOnGlobalLayoutListener(this)
callback()
}
})
fun ViewGroup.startAnimations() {
val transition = AutoTransition()
.setInterpolator(FastOutSlowInInterpolator()).setDuration(400)
TransitionManager.beginDelayedTransition(
this,
transition
)
}
var View.coroutineScope: CoroutineScope
get() = getTag(R.id.coroutineScope) as? CoroutineScope ?: GlobalScope
set(value) = setTag(R.id.coroutineScope, value)
@set:BindingAdapter("precomputedText")
var TextView.precomputedText: CharSequence
get() = text
set(value) {
val callback = tag as? Runnable
// Don't even bother pre 21
if (SDK_INT < 21) {
post {
text = value
isGone = false
callback?.run()
}
return
}
coroutineScope.launch(Dispatchers.IO) {
if (SDK_INT >= 29) {
// Internally PrecomputedTextCompat will platform API on API 29+
// Due to some stupid crap OEM (Samsung) implementation, this can actually
// crash our app. Directly use platform APIs with some workarounds
val pre = PrecomputedText.create(value, textMetricsParams)
post {
try {
text = pre
} catch (e: IllegalArgumentException) {
// Override to computed params to workaround crashes
textMetricsParams = pre.params
text = pre
}
isGone = false
callback?.run()
}
} else {
val tv = this@precomputedText
val params = TextViewCompat.getTextMetricsParams(tv)
val pre = PrecomputedTextCompat.create(value, params)
post {
TextViewCompat.setPrecomputedText(tv, pre)
isGone = false
callback?.run()
}
}
}
}
fun Int.dpInPx(): Int {
val scale = ResMgr.resource.displayMetrics.density
return (this * scale + 0.5).toInt()
}

View File

@ -1,23 +0,0 @@
package com.topjohnwu.magisk.ktx
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
fun View.setOnViewReadyListener(callback: () -> Unit) = addOnGlobalLayoutListener(true, callback)
fun View.addOnGlobalLayoutListener(oneShot: Boolean = false, callback: () -> Unit) =
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if (oneShot) viewTreeObserver.removeOnGlobalLayoutListener(this)
callback()
}
})
fun ViewGroup.startAnimations() {
val transition = AutoTransition().setInterpolator(FastOutSlowInInterpolator()).setDuration(400)
TransitionManager.beginDelayedTransition(this, transition)
}

View File

@ -1,34 +1,21 @@
package com.topjohnwu.magisk.view
import android.content.Context
import android.text.Spanned
import android.view.LayoutInflater
import android.widget.TextView
import androidx.core.text.PrecomputedTextCompat
import androidx.core.widget.TextViewCompat
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.data.repository.StringRepository
import com.topjohnwu.magisk.ktx.coroutineScope
import io.noties.markwon.Markwon
import kotlinx.coroutines.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.KoinComponent
import org.koin.core.inject
import timber.log.Timber
import kotlin.coroutines.coroutineContext
class PrecomputedTextSetter : Markwon.TextSetter {
override fun setText(tv: TextView, text: Spanned, bufferType: TextView.BufferType, onComplete: Runnable) {
val scope = tv.tag as? CoroutineScope ?: GlobalScope
scope.launch(Dispatchers.Default) {
val pre = PrecomputedTextCompat.create(text, TextViewCompat.getTextMetricsParams(tv))
tv.post {
TextViewCompat.setPrecomputedText(tv, pre)
onComplete.run()
}
}
}
}
object MarkDownWindow : KoinComponent {
private val repo: StringRepository by inject()
@ -41,26 +28,27 @@ object MarkDownWindow : KoinComponent {
}
suspend fun show(activity: Context, title: String?, input: suspend () -> String) {
val mdRes = R.layout.markdown_window_md2
val mv = LayoutInflater.from(activity).inflate(mdRes, null)
val tv = mv.findViewById<TextView>(R.id.md_txt)
tv.tag = CoroutineScope(coroutineContext)
try {
markwon.setMarkdown(tv, input())
} catch (e: Exception) {
if (e is CancellationException)
throw e
Timber.e(e)
tv.setText(R.string.download_file_error)
}
val view = LayoutInflater.from(activity).inflate(R.layout.markdown_window_md2, null)
MagiskDialog(activity)
.applyTitle(title ?: "")
.applyView(mv)
.applyView(view)
.applyButton(MagiskDialog.ButtonType.NEGATIVE) {
titleRes = android.R.string.cancel
}
.reveal()
val tv = view.findViewById<TextView>(R.id.md_txt)
tv.coroutineScope = CoroutineScope(coroutineContext)
withContext(Dispatchers.IO) {
try {
markwon.setMarkdown(tv, input())
} catch (e: Exception) {
if (e is CancellationException)
throw e
Timber.e(e)
tv.setText(R.string.download_file_error)
}
}
}
}

View File

@ -233,6 +233,7 @@
android:layout_height="wrap_content"
android:layout_margin="15dp"
android:textAppearance="@style/AppearanceFoundation.Caption"
android:visibility="gone"
markdownText="@{viewModel.notes}"/>
</com.google.android.material.card.MaterialCardView>

View File

@ -2,5 +2,6 @@
<resources>
<item name="recyclerScrollListener" type="id" />
<item name="coroutineScope" type="id"/>
</resources>
</resources>