Added SVG emoji support

Now emojis in SVG format will be decoded and showed the same as a PNG
emoji. Also those emojis are cached, so there are not additional network
calls when requesting a SVG emoji.
This commit is contained in:
Adolfo Santiago 2022-01-29 14:31:14 +01:00
parent 7f84ec5ea9
commit a63c7dc96f
No known key found for this signature in database
GPG key ID: 244D6F9A317B4A65
8 changed files with 324 additions and 47 deletions

View file

@ -217,8 +217,10 @@ dependencies {
implementation(ApplicationLibs.Dagger.daggerSupport)
implementation(ApplicationLibs.Glide.glide)
implementation(ApplicationLibs.Glide.glideOkhttp)
kapt(ApplicationLibs.Glide.glideCompiler)
implementation(ApplicationLibs.Glide.glideImage)
implementation(ApplicationLibs.Glide.glideImageViewFactory)
implementation(ApplicationLibs.Glide.glideOkhttp)
implementation(ApplicationLibs.Google.flexbox)
implementation(ApplicationLibs.Google.exoplayer)
@ -244,14 +246,13 @@ dependencies {
implementation(ApplicationLibs.acraMail)
implementation(ApplicationLibs.acraNotification)
implementation(ApplicationLibs.androidImageCropper)
implementation(ApplicationLibs.androidSvg)
implementation(ApplicationLibs.autodispose)
implementation(ApplicationLibs.autodisposeAndroidArchComp)
implementation(ApplicationLibs.bigImageViewer)
implementation(ApplicationLibs.conscryptAndroid)
implementation(ApplicationLibs.filemojiCompat)
implementation(ApplicationLibs.fragmentviewbindingdelegateKt)
implementation(ApplicationLibs.glideImage)
implementation(ApplicationLibs.glideImageViewFactory)
implementation(ApplicationLibs.markdownEdit)
implementation(ApplicationLibs.materialDrawer)
implementation(ApplicationLibs.materialDrawerIconics)

View file

@ -24,6 +24,7 @@
-keepclassmembers class * extends android.app.Activity {
public void *(android.view.View);
}
# For enumeration classes, see http://proguard.sourceforge.net/manual/examples.html#enumerations
-keepclassmembers enum * {
public static **[] values();

View file

@ -0,0 +1,29 @@
package com.keylesspalace.tusky.core.utils.image
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.PictureDrawable
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.engine.Resource
import com.bumptech.glide.load.resource.SimpleResource
import com.bumptech.glide.load.resource.transcode.ResourceTranscoder
import com.caverock.androidsvg.SVG
class SvgBitmapTranscoder : ResourceTranscoder<SVG, Bitmap> {
override fun transcode(toTranscode: Resource<SVG>, options: Options): Resource<Bitmap> {
val svg = toTranscode.get()
val width = svg.documentWidth.toInt().takeIf { it > 0 }
?: (svg.documentViewBox.right - svg.documentViewBox.left).toInt()
val height = svg.documentHeight.toInt().takeIf { it > 0 }
?: (svg.documentViewBox.bottom - svg.documentViewBox.top).toInt()
val picture = svg.renderToPicture(width, height)
val drawable = PictureDrawable(picture)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawPicture(drawable.picture)
return SimpleResource(bitmap)
}
}

View file

@ -0,0 +1,64 @@
/*
* Husky -- A Pleroma client for Android
*
* Copyright (C) 2021 The Husky Developers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Based on Glide's SVG decoder. Rewriten to Kotlin.
*/
package com.keylesspalace.tusky.core.utils.image
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.ResourceDecoder
import com.bumptech.glide.load.engine.Resource
import com.bumptech.glide.load.resource.SimpleResource
import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException
import java.io.IOException
import java.io.InputStream
class SvgDecoder : ResourceDecoder<InputStream, SVG> {
override fun handles(source: InputStream, options: Options): Boolean {
return true
}
override fun decode(
source: InputStream,
width: Int,
height: Int,
options: Options
): Resource<SVG> {
try {
val svg = SVG.getFromInputStream(source)
if(width != SIZE_ORIGINAL) {
svg.documentWidth = width.toFloat()
}
if(height != SIZE_ORIGINAL) {
svg.documentHeight = height.toFloat()
}
return SimpleResource(svg)
} catch(ex: SVGParseException) {
throw IOException("Cannot load SVG from stream", ex);
}
}
}

View file

@ -0,0 +1,62 @@
/*
* Husky -- A Pleroma client for Android
*
* Copyright (C) 2021 The Husky Developers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Some code is based on https://github.com/qoqa/glide-svg
*
* No copyright asserted on the source code of this repository.
*/
package com.keylesspalace.tusky.core.utils.image
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.PictureDrawable
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.engine.Resource
import com.bumptech.glide.load.resource.SimpleResource
import com.bumptech.glide.load.resource.transcode.ResourceTranscoder
import com.caverock.androidsvg.SVG
class SvgDrawableTranscoder(private val context: Context) : ResourceTranscoder<SVG, Drawable> {
override fun transcode(toTranscode: Resource<SVG>, options: Options): Resource<Drawable> {
return SimpleResource(
BitmapDrawable(
context.resources,
getBitmap(toTranscode.get()).get()
)
)
}
private fun getBitmap(svg: SVG): SimpleResource<Bitmap> {
val width = svg.documentWidth.toInt()
val height = svg.documentHeight.toInt()
val drawable = PictureDrawable(svg.renderToPicture(width, height))
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawPicture(drawable.picture)
return SimpleResource(bitmap)
}
}

View file

@ -0,0 +1,46 @@
/*
* Husky -- A Pleroma client for Android
*
* Copyright (C) 2021 The Husky Developers
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.keylesspalace.tusky.core.utils.image;
import android.content.Context;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.LibraryGlideModule;
import com.caverock.androidsvg.SVG;
import java.io.InputStream;
/**
* Module for adding SVG decoding support to Glide.
*/
@GlideModule
public class SvgModule extends LibraryGlideModule {
@Override
public void registerComponents(
@NonNull Context context,
@NonNull Glide glide,
@NonNull Registry registry) {
registry.register(SVG.class, Drawable.class, new SvgDrawableTranscoder(context))
.append(InputStream.class, SVG.class, new SvgDecoder());
}
}

View file

@ -1,19 +1,25 @@
/* Copyright 2020 Tusky Contributors
/*
* Husky -- A Pleroma client for Android
*
* This file is a part of Tusky.
* Copyright (C) 2021 The Husky Developers
* Copyright (C) 2021 Tusky Contributors
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:JvmName("CustomEmojiHelper")
package com.keylesspalace.tusky.util
import android.graphics.Canvas
@ -23,28 +29,35 @@ import android.text.SpannableString
import android.text.Spanned
import android.text.style.ReplacementSpan
import android.view.View
import android.webkit.MimeTypeMap
import androidx.preference.PreferenceManager
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.Target
import com.bumptech.glide.request.transition.Transition
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.settings.PrefKeys
import java.lang.ref.WeakReference
import java.util.regex.Pattern
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.settings.PrefKeys
/**
* replaces emoji shortcodes in a text with EmojiSpans
* @param text the text containing custom emojis
* @param emojis a list of the custom emojis (nullable for backward compatibility with old mastodon instances)
* @param view a reference to the a view the emojis will be shown in (should be the TextView, but parents of the TextView are also acceptable)
* @return the text with the shortcodes replaced by EmojiSpans
*/
fun CharSequence.emojify(emojis: List<Emoji>?, view: View, forceSmallEmoji: Boolean) : CharSequence {
if(emojis.isNullOrEmpty())
* Replaces emoji shortcodes in a text with EmojiSpans.
*
* @param text the text containing custom emojis.
* @param emojis a list of the custom emojis (nullable for backward compatibility with old mastodon instances).
* @param view a reference to the a view the emojis will be shown in (should be the TextView, but parents of the TextView are also acceptable).
*
* @return The text with the shortcodes replaced by EmojiSpans
*/
fun CharSequence.emojify(
emojis: List<Emoji>?,
view: View,
forceSmallEmoji: Boolean = false
): CharSequence {
if(emojis.isNullOrEmpty()) {
return this
}
val builder = SpannableString.valueOf(this)
val pm = PreferenceManager.getDefaultSharedPreferences(view.context)
@ -63,24 +76,36 @@ fun CharSequence.emojify(emojis: List<Emoji>?, view: View, forceSmallEmoji: Bool
}
builder.setSpan(span, matcher.start(), matcher.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
Glide.with(view)
.asDrawable()
.load(url)
.into(span.getTarget(animate))
var glideRequest = Glide.with(view).load(url)
val mimetype = getMimeType(url)
if(mimetype == MIME.SVG) {
glideRequest = glideRequest
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.override(512, 512)
}
glideRequest.into(span.getTarget(animate))
}
}
return builder
}
fun CharSequence.emojify(emojis: List<Emoji>?, view: View) : CharSequence {
fun CharSequence.emojify(emojis: List<Emoji>?, view: View): CharSequence {
return this.emojify(emojis, view, false)
}
open class EmojiSpan(val viewWeakReference: WeakReference<View>) : ReplacementSpan() {
var imageDrawable: Drawable? = null
override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?) : Int {
if (fm != null) {
override fun getSize(
paint: Paint,
text: CharSequence,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
if(fm != null) {
/* update FontMetricsInt or otherwise span does not get drawn when
* it covers the whole text */
val metrics = paint.fontMetricsInt
@ -89,11 +114,21 @@ open class EmojiSpan(val viewWeakReference: WeakReference<View>) : ReplacementSp
fm.descent = (metrics.descent * 2.0f).toInt()
fm.bottom = (metrics.bottom * 3.5f).toInt()
}
return (paint.textSize * 2.0).toInt()
}
override fun draw(canvas: Canvas, text: CharSequence, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
override fun draw(
canvas: Canvas,
text: CharSequence,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
imageDrawable?.let { drawable ->
canvas.save()
@ -108,21 +143,24 @@ open class EmojiSpan(val viewWeakReference: WeakReference<View>) : ReplacementSp
canvas.restore()
}
}
fun getTarget(animate : Boolean): Target<Drawable> {
fun getTarget(animate: Boolean): Target<Drawable> {
return object : CustomTarget<Drawable>() {
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
viewWeakReference.get()?.let { view ->
if(animate && resource is Animatable) {
val callback = resource.callback
resource.callback = object: Drawable.Callback {
resource.callback = object : Drawable.Callback {
override fun unscheduleDrawable(p0: Drawable, p1: Runnable) {
callback?.unscheduleDrawable(p0, p1)
}
override fun scheduleDrawable(p0: Drawable, p1: Runnable, p2: Long) {
callback?.scheduleDrawable(p0, p1, p2)
}
override fun invalidateDrawable(p0: Drawable) {
callback?.invalidateDrawable(p0)
view.invalidate()
@ -135,16 +173,21 @@ open class EmojiSpan(val viewWeakReference: WeakReference<View>) : ReplacementSp
view.invalidate()
}
}
override fun onLoadCleared(placeholder: Drawable?) {}
}
}
}
class SmallEmojiSpan(viewWeakReference: WeakReference<View>)
: EmojiSpan(viewWeakReference) {
override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
if (fm != null) {
class SmallEmojiSpan(viewWeakReference: WeakReference<View>) : EmojiSpan(viewWeakReference) {
override fun getSize(
paint: Paint,
text: CharSequence,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
if(fm != null) {
/* update FontMetricsInt or otherwise span does not get drawn when
* it covers the whole text */
val metrics = paint.fontMetricsInt
@ -157,3 +200,32 @@ class SmallEmojiSpan(viewWeakReference: WeakReference<View>)
return paint.textSize.toInt()
}
}
/**
* Get the Mimetype fron the URL.
*
* @return MIME - The Mimetype.
*/
private fun getMimeType(url: String?): MIME {
var type: String? = null
val extension = MimeTypeMap.getFileExtensionFromUrl(url)
if(extension != null) {
type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
}
return MIME.getMime(type)
}
private enum class MIME(private val mimetype: String) {
NONE(""),
SVG("image/svg+xml");
companion object {
fun getMime(mime: String?): MIME {
return when(mime) {
SVG.mimetype -> SVG
else -> NONE
}
}
}
}

View file

@ -16,6 +16,7 @@ object ApplicationLibs {
private object Versions {
const val acra = "5.8.4"
const val androidImageCropper = "2.8.0"
const val androidSvg = "1.4"
const val appcompat = "1.3.1"
const val autodispose = "1.4.0"
const val bigImageViewer = "1.7.0"
@ -105,6 +106,9 @@ object ApplicationLibs {
object Glide {
const val glide = "com.github.bumptech.glide:glide:${Versions.glide}"
const val glideCompiler = "com.github.bumptech.glide:compiler:${Versions.glide}"
const val glideImage = "com.github.piasy:GlideImageLoader:${Versions.glideImage}"
const val glideImageViewFactory =
"com.github.piasy:GlideImageViewFactory:${Versions.glideImage}"
const val glideOkhttp = "com.github.bumptech.glide:okhttp3-integration:${Versions.glide}"
}
@ -145,6 +149,7 @@ object ApplicationLibs {
const val acraMail = "ch.acra:acra-mail:${Versions.acra}"
const val acraNotification = "ch.acra:acra-notification:${Versions.acra}"
const val androidSvg = "com.caverock:androidsvg-aar:${Versions.androidSvg}"
const val androidImageCropper =
"com.theartofdev.edmodo:android-image-cropper:${Versions.androidImageCropper}"
const val autodispose = "com.uber.autodispose:autodispose:${Versions.autodispose}"
@ -155,9 +160,6 @@ object ApplicationLibs {
const val filemojiCompat = "de.c1710:filemojicompat:${Versions.filemojiCompat}"
const val fragmentviewbindingdelegateKt =
"com.github.Zhuinden:fragmentviewbindingdelegate-kt:${Versions.fragmentviewbindingdelegateKt}"
const val glideImage = "com.github.piasy:GlideImageLoader:${Versions.glideImage}"
const val glideImageViewFactory =
"com.github.piasy:GlideImageViewFactory:${Versions.glideImage}"
const val markdownEdit = "com.github.Tunous:MarkdownEdit:${Versions.markdownEdit}"
const val materialDrawer = "com.mikepenz:materialdrawer:${Versions.materialDrawer}"
const val materialDrawerIconics =