From ac2a9da4c41364f011c1e5a8e4071bbe1a879884 Mon Sep 17 00:00:00 2001 From: topjohnwu Date: Fri, 14 Aug 2020 02:00:06 -0700 Subject: [PATCH] Fix Markdown rendering Close #3074 --- .../magisk/core/utils/MarkwonImagePlugin.kt | 244 ++++++++++++++++++ .../com/topjohnwu/magisk/core/utils/Utils.kt | 6 - .../magisk/databinding/AdaptersGeneric.kt | 20 +- .../topjohnwu/magisk/di/NetworkingModule.kt | 14 +- .../java/com/topjohnwu/magisk/ktx/XAndroid.kt | 92 +++++++ .../java/com/topjohnwu/magisk/ktx/XView.kt | 23 -- .../topjohnwu/magisk/view/MarkDownWindow.kt | 52 ++-- .../main/res/layout/fragment_install_md2.xml | 1 + app/src/main/res/values/ids.xml | 3 +- 9 files changed, 371 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/com/topjohnwu/magisk/core/utils/MarkwonImagePlugin.kt delete mode 100644 app/src/main/java/com/topjohnwu/magisk/ktx/XView.kt diff --git a/app/src/main/java/com/topjohnwu/magisk/core/utils/MarkwonImagePlugin.kt b/app/src/main/java/com/topjohnwu/magisk/core/utils/MarkwonImagePlugin.kt new file mode 100644 index 000000000..d8a83183d --- /dev/null +++ b/app/src/main/java/com/topjohnwu/magisk/core/utils/MarkwonImagePlugin.kt @@ -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(3) + private val mediaDecoders = HashMap(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) + } + + } +} diff --git a/app/src/main/java/com/topjohnwu/magisk/core/utils/Utils.kt b/app/src/main/java/com/topjohnwu/magisk/core/utils/Utils.kt index 204eb0668..8757b23fc 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/utils/Utils.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/utils/Utils.kt @@ -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().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) diff --git a/app/src/main/java/com/topjohnwu/magisk/databinding/AdaptersGeneric.kt b/app/src/main/java/com/topjohnwu/magisk/databinding/AdaptersGeneric.kt index fd6f5a69e..63fc61199 100644 --- a/app/src/main/java/com/topjohnwu/magisk/databinding/AdaptersGeneric.kt +++ b/app/src/main/java/com/topjohnwu/magisk/databinding/AdaptersGeneric.kt @@ -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.setMarkdown(tv, text.toString()) + tv.coroutineScope.launch(Dispatchers.IO) { + val markwon = get() + markwon.setMarkdown(tv, text.toString()) + } } diff --git a/app/src/main/java/com/topjohnwu/magisk/di/NetworkingModule.kt b/app/src/main/java/com/topjohnwu/magisk/di/NetworkingModule.kt index 5502d308b..34b0b3327 100644 --- a/app/src/main/java/com/topjohnwu/magisk/di/NetworkingModule.kt +++ b/app/src/main/java/com/topjohnwu/magisk/di/NetworkingModule.kt @@ -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 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() } diff --git a/app/src/main/java/com/topjohnwu/magisk/ktx/XAndroid.kt b/app/src/main/java/com/topjohnwu/magisk/ktx/XAndroid.kt index b351409cb..1f397e0e9 100644 --- a/app/src/main/java/com/topjohnwu/magisk/ktx/XAndroid.kt +++ b/app/src/main/java/com/topjohnwu/magisk/ktx/XAndroid.kt @@ -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() +} diff --git a/app/src/main/java/com/topjohnwu/magisk/ktx/XView.kt b/app/src/main/java/com/topjohnwu/magisk/ktx/XView.kt deleted file mode 100644 index ec51d0b50..000000000 --- a/app/src/main/java/com/topjohnwu/magisk/ktx/XView.kt +++ /dev/null @@ -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) -} diff --git a/app/src/main/java/com/topjohnwu/magisk/view/MarkDownWindow.kt b/app/src/main/java/com/topjohnwu/magisk/view/MarkDownWindow.kt index 9da1056a7..788447f76 100644 --- a/app/src/main/java/com/topjohnwu/magisk/view/MarkDownWindow.kt +++ b/app/src/main/java/com/topjohnwu/magisk/view/MarkDownWindow.kt @@ -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(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(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) + } + } } } diff --git a/app/src/main/res/layout/fragment_install_md2.xml b/app/src/main/res/layout/fragment_install_md2.xml index 066ae40a6..80d8987e0 100644 --- a/app/src/main/res/layout/fragment_install_md2.xml +++ b/app/src/main/res/layout/fragment_install_md2.xml @@ -233,6 +233,7 @@ android:layout_height="wrap_content" android:layout_margin="15dp" android:textAppearance="@style/AppearanceFoundation.Caption" + android:visibility="gone" markdownText="@{viewModel.notes}"/> diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 77b8f273e..398106f66 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -2,5 +2,6 @@ + - \ No newline at end of file +