replace HtmlUtils with HtmlCompat (#1741)

* replace HtmlUtils with HtmlCompat

* fix tests
This commit is contained in:
Konrad Pozniak 2020-04-02 23:37:38 +02:00 committed by GitHub
parent f7434564df
commit 68f34152dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 65 additions and 165 deletions

View File

@ -20,6 +20,7 @@ import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.text.HtmlCompat;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@ -38,7 +39,6 @@ import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.HtmlUtils;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
@ -884,7 +884,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected CharSequence getFavsText(Context context, int count) {
if (count > 0) {
String countString = numberFormat.format(count);
return HtmlUtils.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString));
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
} else {
return "";
}
@ -893,7 +893,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected CharSequence getReblogsText(Context context, int count) {
if (count > 0) {
String countString = numberFormat.format(count);
return HtmlUtils.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString));
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
} else {
return "";
}

View File

@ -16,6 +16,8 @@
package com.keylesspalace.tusky.db
import android.text.Spanned
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
import androidx.room.TypeConverter
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
@ -27,7 +29,6 @@ import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.util.HtmlUtils
import java.net.URLDecoder
import java.net.URLEncoder
import java.util.*
@ -128,7 +129,7 @@ class Converters {
if(spanned == null) {
return null
}
return HtmlUtils.toHtml(spanned)
return spanned.toHtml()
}
@TypeConverter
@ -136,7 +137,7 @@ class Converters {
if(spannedString == null) {
return null
}
return HtmlUtils.fromHtml(spannedString)
return spannedString.parseAsHtml()
}
@TypeConverter

View File

@ -29,8 +29,6 @@ import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.network.TimelineCasesImpl
import com.keylesspalace.tusky.util.HtmlConverter
import com.keylesspalace.tusky.util.HtmlConverterImpl
import dagger.Module
import dagger.Provides
import javax.inject.Singleton
@ -84,9 +82,4 @@ class AppModule {
.build()
}
@Provides
@Singleton
fun providesHtmlConverter(): HtmlConverter {
return HtmlConverterImpl()
}
}

View File

@ -6,17 +6,18 @@ import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.TimelineRepository
import com.keylesspalace.tusky.repository.TimelineRepositoryImpl
import com.keylesspalace.tusky.util.HtmlConverter
import dagger.Module
import dagger.Provides
@Module
class RepositoryModule {
@Provides
fun providesTimelineRepository(db: AppDatabase, mastodonApi: MastodonApi,
accountManager: AccountManager, gson: Gson,
htmlConverter: HtmlConverter): TimelineRepository {
return TimelineRepositoryImpl(db.timelineDao(), mastodonApi, accountManager, gson,
htmlConverter)
fun providesTimelineRepository(
db: AppDatabase,
mastodonApi: MastodonApi,
accountManager: AccountManager,
gson: Gson
): TimelineRepository {
return TimelineRepositoryImpl(db.timelineDao(), mastodonApi, accountManager, gson)
}
}

View File

@ -15,24 +15,16 @@
package com.keylesspalace.tusky.entity
import android.os.Parcel
import android.os.Parcelable
import android.text.Spanned
import com.google.gson.annotations.SerializedName
import com.keylesspalace.tusky.util.HtmlUtils
import kotlinx.android.parcel.Parceler
import kotlinx.android.parcel.Parcelize
import kotlinx.android.parcel.WriteWith
import java.util.*
import java.util.Date
@Parcelize
data class Account(
val id: String,
@SerializedName("username") val localUsername: String,
@SerializedName("acct") val username: String,
@SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract
val note: @WriteWith<SpannedParceler>() Spanned,
val note: Spanned,
val url: String,
val avatar: String,
val header: String,
@ -46,7 +38,7 @@ data class Account(
val fields: List<Field>? = emptyList(), //nullable for backward compatibility
val moved: Account? = null
) : Parcelable {
) {
val name: String
get() = if (displayName.isNullOrEmpty()) {
@ -86,31 +78,20 @@ data class Account(
fun isRemote(): Boolean = this.username != this.localUsername
}
@Parcelize
data class AccountSource(
val privacy: Status.Visibility,
val sensitive: Boolean,
val note: String,
val fields: List<StringField>?
): Parcelable
)
@Parcelize
data class Field (
val name: String,
val value: @WriteWith<SpannedParceler>() Spanned,
val value: Spanned,
@SerializedName("verified_at") val verifiedAt: Date?
): Parcelable
)
@Parcelize
data class StringField (
val name: String,
val value: String
): Parcelable
object SpannedParceler : Parceler<Spanned> {
override fun create(parcel: Parcel): Spanned = HtmlUtils.fromHtml(parcel.readString())
override fun Spanned.write(parcel: Parcel, flags: Int) {
parcel.writeString(HtmlUtils.toHtml(this))
}
}
)

View File

@ -15,23 +15,19 @@
package com.keylesspalace.tusky.entity
import android.os.Parcelable
import android.text.Spanned
import com.google.gson.annotations.SerializedName
import kotlinx.android.parcel.Parcelize
import kotlinx.android.parcel.WriteWith
@Parcelize
data class Card(
val url: String,
val title: @WriteWith<SpannedParceler>() Spanned,
val description: @WriteWith<SpannedParceler>() Spanned,
val title: Spanned,
val description: Spanned,
@SerializedName("author_name") val authorName: String,
val image: String,
val type: String,
val width: Int,
val height: Int
) : Parcelable {
) {
override fun hashCode(): Int {
return url.hashCode()

View File

@ -18,6 +18,8 @@ package com.keylesspalace.tusky.json;
import android.text.Spanned;
import android.text.SpannedString;
import androidx.core.text.HtmlCompat;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
@ -25,7 +27,6 @@ import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import com.keylesspalace.tusky.util.HtmlUtils;
import java.lang.reflect.Type;
@ -35,7 +36,9 @@ public class SpannedTypeAdapter implements JsonDeserializer<Spanned>, JsonSerial
throws JsonParseException {
String string = json.getAsString();
if (string != null) {
return HtmlUtils.fromHtml(string);
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
* all status contents do, so it should be trimmed. */
return (Spanned)trimTrailingWhitespace(HtmlCompat.fromHtml(string, HtmlCompat.FROM_HTML_MODE_LEGACY));
} else {
return new SpannedString("");
}
@ -43,6 +46,14 @@ public class SpannedTypeAdapter implements JsonDeserializer<Spanned>, JsonSerial
@Override
public JsonElement serialize(Spanned src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(HtmlUtils.toHtml(src));
return new JsonPrimitive(HtmlCompat.toHtml(src, HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL));
}
private static CharSequence trimTrailingWhitespace(CharSequence s) {
int i = s.length();
do {
i--;
} while (i >= 0 && Character.isWhitespace(s.charAt(i)));
return s.subSequence(0, i + 1);
}
}

View File

@ -1,6 +1,8 @@
package com.keylesspalace.tusky.repository
import android.text.SpannedString
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.*
@ -9,7 +11,6 @@ import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK
import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.HtmlConverter
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.inc
import io.reactivex.Single
@ -40,8 +41,7 @@ class TimelineRepositoryImpl(
private val timelineDao: TimelineDao,
private val mastodonApi: MastodonApi,
private val accountManager: AccountManager,
private val gson: Gson,
private val htmlConverter: HtmlConverter
private val gson: Gson
) : TimelineRepository {
init {
@ -150,7 +150,7 @@ class TimelineRepositoryImpl(
for (status in statuses) {
timelineDao.insertInTransaction(
status.toEntity(accountId, htmlConverter, gson),
status.toEntity(accountId, gson),
status.account.toEntity(accountId, gson),
status.reblog?.account?.toEntity(accountId, gson)
)
@ -214,7 +214,7 @@ class TimelineRepositoryImpl(
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
content = status.content?.let(htmlConverter::fromHtml) ?: SpannedString(""),
content = status.content?.parseAsHtml() ?: SpannedString(""),
createdAt = Date(status.createdAt),
emojis = emojis,
reblogsCount = status.reblogsCount,
@ -269,7 +269,7 @@ class TimelineRepositoryImpl(
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
content = status.content?.let(htmlConverter::fromHtml) ?: SpannedString(""),
content = status.content?.parseAsHtml() ?: SpannedString(""),
createdAt = Date(status.createdAt),
emojis = emojis,
reblogsCount = status.reblogsCount,
@ -362,7 +362,6 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
}
fun Status.toEntity(timelineUserId: Long,
htmlConverter: HtmlConverter,
gson: Gson): TimelineStatusEntity {
val actionable = actionableStatus
return TimelineStatusEntity(
@ -372,7 +371,7 @@ fun Status.toEntity(timelineUserId: Long,
authorServerId = actionable.account.id,
inReplyToId = actionable.inReplyToId,
inReplyToAccountId = actionable.inReplyToAccountId,
content = htmlConverter.toHtml(actionable.content),
content = actionable.content.toHtml(),
createdAt = actionable.createdAt.time,
emojis = actionable.emojis.let(gson::toJson),
reblogsCount = actionable.reblogsCount,
@ -385,7 +384,7 @@ fun Status.toEntity(timelineUserId: Long,
visibility = actionable.visibility,
attachments = actionable.attachments.let(gson::toJson),
mentions = actionable.mentions.let(gson::toJson),
application = actionable.let(gson::toJson),
application = actionable.application.let(gson::toJson),
reblogServerId = reblog?.id,
reblogAccountId = reblog?.let { this.account.id },
poll = actionable.poll.let(gson::toJson),

View File

@ -1,22 +0,0 @@
package com.keylesspalace.tusky.util
import android.text.Spanned
/**
* Abstracting away Android-specific things.
*/
interface HtmlConverter {
fun fromHtml(html: String): Spanned
fun toHtml(text: Spanned): String
}
internal class HtmlConverterImpl : HtmlConverter {
override fun fromHtml(html: String): Spanned {
return HtmlUtils.fromHtml(html)
}
override fun toHtml(text: Spanned): String {
return HtmlUtils.toHtml(text)
}
}

View File

@ -1,52 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* 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.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.util;
import android.os.Build;
import android.text.Html;
import android.text.Spanned;
public class HtmlUtils {
private static CharSequence trimTrailingWhitespace(CharSequence s) {
int i = s.length();
do {
i--;
} while (i >= 0 && Character.isWhitespace(s.charAt(i)));
return s.subSequence(0, i + 1);
}
public static Spanned fromHtml(String html) {
Spanned result;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
result = Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY);
} else {
result = Html.fromHtml(html);
}
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
* all status contents do, so it should be trimmed. */
return (Spanned) trimTrailingWhitespace(result);
}
public static String toHtml(Spanned text) {
String result;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
result = Html.toHtml(text, Html.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE);
} else {
result = Html.toHtml(text);
}
return result;
}
}

View File

@ -171,15 +171,12 @@ class StatusViewHelper(private val itemView: View) {
sensitiveMediaWarning.visibility = View.GONE
sensitiveMediaShow.visibility = View.GONE
} else {
val hiddenContentText: String = if (sensitive) {
sensitiveMediaWarning.text = if (sensitive) {
context.getString(R.string.status_sensitive_media_title)
} else {
context.getString(R.string.status_media_hidden_title)
}
sensitiveMediaWarning.text = HtmlUtils.fromHtml(hiddenContentText)
sensitiveMediaWarning.visibility = if (showingContent) View.GONE else View.VISIBLE
sensitiveMediaShow.visibility = if (showingContent) View.VISIBLE else View.GONE
sensitiveMediaShow.setOnClickListener { v ->

View File

@ -18,10 +18,10 @@ package com.keylesspalace.tusky.viewdata
import android.content.Context
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.core.text.parseAsHtml
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.PollOption
import com.keylesspalace.tusky.util.HtmlUtils
import java.util.*
import kotlin.math.roundToInt
@ -52,7 +52,7 @@ fun calculatePercent(fraction: Int, totalVoters: Int?, totalVotes: Int): Int {
}
fun buildDescription(title: String, percent: Int, context: Context): Spanned {
return SpannableStringBuilder(HtmlUtils.fromHtml(context.getString(R.string.poll_percent_format, percent)))
return SpannableStringBuilder(context.getString(R.string.poll_percent_format, percent).parseAsHtml())
.append(" ")
.append(title)
}

View File

@ -1,6 +1,8 @@
package com.keylesspalace.tusky.fragment
import android.text.Spanned
import android.text.SpannableString
import android.text.SpannedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.gson.Gson
import com.keylesspalace.tusky.SpanUtilsTest
import com.keylesspalace.tusky.db.AccountEntity
@ -12,7 +14,6 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.*
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.HtmlConverter
import com.nhaarman.mockitokotlin2.isNull
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
@ -24,14 +25,18 @@ import io.reactivex.schedulers.TestScheduler
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.robolectric.annotation.Config
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.collections.ArrayList
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
class TimelineRepositoryTest {
@Mock
lateinit var timelineDao: TimelineDao
@ -56,15 +61,6 @@ class TimelineRepositoryTest {
domain = "domain.com",
isActive = true
)
private val htmlConverter = object : HtmlConverter {
override fun fromHtml(html: String): Spanned {
return SpanUtilsTest.FakeSpannable(html)
}
override fun toHtml(text: Spanned): String {
return text.toString()
}
}
@Before
fun setup() {
@ -74,8 +70,7 @@ class TimelineRepositoryTest {
gson = Gson()
testScheduler = TestScheduler()
RxJavaPlugins.setIoSchedulerHandler { testScheduler }
subject = TimelineRepositoryImpl(timelineDao, mastodonApi, accountManager, gson,
htmlConverter)
subject = TimelineRepositoryImpl(timelineDao, mastodonApi, accountManager, gson)
}
@Test
@ -97,7 +92,7 @@ class TimelineRepositoryTest {
verify(timelineDao).insertStatusIfNotThere(Placeholder("1").toEntity(account.id))
for (status in statuses) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, htmlConverter, gson),
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
@ -129,7 +124,7 @@ class TimelineRepositoryTest {
// We assume for now that overlapped one is inserted but it's not that important
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, htmlConverter, gson),
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
@ -159,7 +154,7 @@ class TimelineRepositoryTest {
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, htmlConverter, gson),
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
@ -201,7 +196,7 @@ class TimelineRepositoryTest {
// We assume for now that overlapped one is inserted but it's not that important
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, htmlConverter, gson),
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
@ -246,7 +241,7 @@ class TimelineRepositoryTest {
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, htmlConverter, gson),
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
@ -263,7 +258,7 @@ class TimelineRepositoryTest {
val status = makeStatus("2")
val dbStatus = makeStatus("1")
val dbResult = TimelineStatusWithAccount()
dbResult.status = dbStatus.toEntity(account.id, htmlConverter, gson)
dbResult.status = dbStatus.toEntity(account.id, gson)
dbResult.account = status.account.toEntity(account.id, gson)
whenever(mastodonApi.homeTimelineSingle(any(), any(), any()))
@ -297,7 +292,7 @@ class TimelineRepositoryTest {
return Status(
id = id,
account = account,
content = SpanUtilsTest.FakeSpannable("hello$id"),
content = SpannableString("hello$id"),
createdAt = Date(),
emojis = listOf(),
reblogsCount = 3,
@ -328,7 +323,7 @@ class TimelineRepositoryTest {
localUsername = "test$id",
username = "test$id@example.com",
displayName = "Example Account $id",
note = SpanUtilsTest.FakeSpannable("Note! $id"),
note = SpannableString("Note! $id"),
url = "https://example.com/@test$id",
avatar = "avatar$id",
header = "Header$id",