replace HtmlUtils with HtmlCompat (#1741)
* replace HtmlUtils with HtmlCompat * fix tests
This commit is contained in:
parent
f7434564df
commit
68f34152dc
13 changed files with 65 additions and 165 deletions
|
@ -20,6 +20,7 @@ import androidx.annotation.DrawableRes;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
|
import androidx.core.text.HtmlCompat;
|
||||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
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.interfaces.StatusActionListener;
|
||||||
import com.keylesspalace.tusky.util.CardViewMode;
|
import com.keylesspalace.tusky.util.CardViewMode;
|
||||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||||
import com.keylesspalace.tusky.util.HtmlUtils;
|
|
||||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||||
import com.keylesspalace.tusky.util.LinkHelper;
|
import com.keylesspalace.tusky.util.LinkHelper;
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||||
|
@ -884,7 +884,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
protected CharSequence getFavsText(Context context, int count) {
|
protected CharSequence getFavsText(Context context, int count) {
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
String countString = numberFormat.format(count);
|
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 {
|
} else {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
@ -893,7 +893,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
protected CharSequence getReblogsText(Context context, int count) {
|
protected CharSequence getReblogsText(Context context, int count) {
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
String countString = numberFormat.format(count);
|
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 {
|
} else {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,8 @@
|
||||||
package com.keylesspalace.tusky.db
|
package com.keylesspalace.tusky.db
|
||||||
|
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
|
import androidx.core.text.parseAsHtml
|
||||||
|
import androidx.core.text.toHtml
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
import com.google.gson.reflect.TypeToken
|
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.Poll
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.json.SpannedTypeAdapter
|
import com.keylesspalace.tusky.json.SpannedTypeAdapter
|
||||||
import com.keylesspalace.tusky.util.HtmlUtils
|
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
@ -128,7 +129,7 @@ class Converters {
|
||||||
if(spanned == null) {
|
if(spanned == null) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return HtmlUtils.toHtml(spanned)
|
return spanned.toHtml()
|
||||||
}
|
}
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
|
@ -136,7 +137,7 @@ class Converters {
|
||||||
if(spannedString == null) {
|
if(spannedString == null) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return HtmlUtils.fromHtml(spannedString)
|
return spannedString.parseAsHtml()
|
||||||
}
|
}
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
|
|
|
@ -29,8 +29,6 @@ import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.network.TimelineCases
|
import com.keylesspalace.tusky.network.TimelineCases
|
||||||
import com.keylesspalace.tusky.network.TimelineCasesImpl
|
import com.keylesspalace.tusky.network.TimelineCasesImpl
|
||||||
import com.keylesspalace.tusky.util.HtmlConverter
|
|
||||||
import com.keylesspalace.tusky.util.HtmlConverterImpl
|
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
@ -84,9 +82,4 @@ class AppModule {
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun providesHtmlConverter(): HtmlConverter {
|
|
||||||
return HtmlConverterImpl()
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -6,17 +6,18 @@ import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.repository.TimelineRepository
|
import com.keylesspalace.tusky.repository.TimelineRepository
|
||||||
import com.keylesspalace.tusky.repository.TimelineRepositoryImpl
|
import com.keylesspalace.tusky.repository.TimelineRepositoryImpl
|
||||||
import com.keylesspalace.tusky.util.HtmlConverter
|
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
class RepositoryModule {
|
class RepositoryModule {
|
||||||
@Provides
|
@Provides
|
||||||
fun providesTimelineRepository(db: AppDatabase, mastodonApi: MastodonApi,
|
fun providesTimelineRepository(
|
||||||
accountManager: AccountManager, gson: Gson,
|
db: AppDatabase,
|
||||||
htmlConverter: HtmlConverter): TimelineRepository {
|
mastodonApi: MastodonApi,
|
||||||
return TimelineRepositoryImpl(db.timelineDao(), mastodonApi, accountManager, gson,
|
accountManager: AccountManager,
|
||||||
htmlConverter)
|
gson: Gson
|
||||||
|
): TimelineRepository {
|
||||||
|
return TimelineRepositoryImpl(db.timelineDao(), mastodonApi, accountManager, gson)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -15,24 +15,16 @@
|
||||||
|
|
||||||
package com.keylesspalace.tusky.entity
|
package com.keylesspalace.tusky.entity
|
||||||
|
|
||||||
import android.os.Parcel
|
|
||||||
import android.os.Parcelable
|
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
import com.keylesspalace.tusky.util.HtmlUtils
|
import java.util.Date
|
||||||
import kotlinx.android.parcel.Parceler
|
|
||||||
import kotlinx.android.parcel.Parcelize
|
|
||||||
import kotlinx.android.parcel.WriteWith
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class Account(
|
data class Account(
|
||||||
val id: String,
|
val id: String,
|
||||||
@SerializedName("username") val localUsername: String,
|
@SerializedName("username") val localUsername: String,
|
||||||
@SerializedName("acct") val username: 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
|
@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 url: String,
|
||||||
val avatar: String,
|
val avatar: String,
|
||||||
val header: String,
|
val header: String,
|
||||||
|
@ -46,7 +38,7 @@ data class Account(
|
||||||
val fields: List<Field>? = emptyList(), //nullable for backward compatibility
|
val fields: List<Field>? = emptyList(), //nullable for backward compatibility
|
||||||
val moved: Account? = null
|
val moved: Account? = null
|
||||||
|
|
||||||
) : Parcelable {
|
) {
|
||||||
|
|
||||||
val name: String
|
val name: String
|
||||||
get() = if (displayName.isNullOrEmpty()) {
|
get() = if (displayName.isNullOrEmpty()) {
|
||||||
|
@ -86,31 +78,20 @@ data class Account(
|
||||||
fun isRemote(): Boolean = this.username != this.localUsername
|
fun isRemote(): Boolean = this.username != this.localUsername
|
||||||
}
|
}
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class AccountSource(
|
data class AccountSource(
|
||||||
val privacy: Status.Visibility,
|
val privacy: Status.Visibility,
|
||||||
val sensitive: Boolean,
|
val sensitive: Boolean,
|
||||||
val note: String,
|
val note: String,
|
||||||
val fields: List<StringField>?
|
val fields: List<StringField>?
|
||||||
): Parcelable
|
)
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class Field (
|
data class Field (
|
||||||
val name: String,
|
val name: String,
|
||||||
val value: @WriteWith<SpannedParceler>() Spanned,
|
val value: Spanned,
|
||||||
@SerializedName("verified_at") val verifiedAt: Date?
|
@SerializedName("verified_at") val verifiedAt: Date?
|
||||||
): Parcelable
|
)
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class StringField (
|
data class StringField (
|
||||||
val name: String,
|
val name: String,
|
||||||
val value: 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -15,23 +15,19 @@
|
||||||
|
|
||||||
package com.keylesspalace.tusky.entity
|
package com.keylesspalace.tusky.entity
|
||||||
|
|
||||||
import android.os.Parcelable
|
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
import kotlinx.android.parcel.Parcelize
|
|
||||||
import kotlinx.android.parcel.WriteWith
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class Card(
|
data class Card(
|
||||||
val url: String,
|
val url: String,
|
||||||
val title: @WriteWith<SpannedParceler>() Spanned,
|
val title: Spanned,
|
||||||
val description: @WriteWith<SpannedParceler>() Spanned,
|
val description: Spanned,
|
||||||
@SerializedName("author_name") val authorName: String,
|
@SerializedName("author_name") val authorName: String,
|
||||||
val image: String,
|
val image: String,
|
||||||
val type: String,
|
val type: String,
|
||||||
val width: Int,
|
val width: Int,
|
||||||
val height: Int
|
val height: Int
|
||||||
) : Parcelable {
|
) {
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
||||||
return url.hashCode()
|
return url.hashCode()
|
||||||
|
|
|
@ -18,6 +18,8 @@ package com.keylesspalace.tusky.json;
|
||||||
import android.text.Spanned;
|
import android.text.Spanned;
|
||||||
import android.text.SpannedString;
|
import android.text.SpannedString;
|
||||||
|
|
||||||
|
import androidx.core.text.HtmlCompat;
|
||||||
|
|
||||||
import com.google.gson.JsonDeserializationContext;
|
import com.google.gson.JsonDeserializationContext;
|
||||||
import com.google.gson.JsonDeserializer;
|
import com.google.gson.JsonDeserializer;
|
||||||
import com.google.gson.JsonElement;
|
import com.google.gson.JsonElement;
|
||||||
|
@ -25,7 +27,6 @@ import com.google.gson.JsonParseException;
|
||||||
import com.google.gson.JsonPrimitive;
|
import com.google.gson.JsonPrimitive;
|
||||||
import com.google.gson.JsonSerializationContext;
|
import com.google.gson.JsonSerializationContext;
|
||||||
import com.google.gson.JsonSerializer;
|
import com.google.gson.JsonSerializer;
|
||||||
import com.keylesspalace.tusky.util.HtmlUtils;
|
|
||||||
|
|
||||||
import java.lang.reflect.Type;
|
import java.lang.reflect.Type;
|
||||||
|
|
||||||
|
@ -35,7 +36,9 @@ public class SpannedTypeAdapter implements JsonDeserializer<Spanned>, JsonSerial
|
||||||
throws JsonParseException {
|
throws JsonParseException {
|
||||||
String string = json.getAsString();
|
String string = json.getAsString();
|
||||||
if (string != null) {
|
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 {
|
} else {
|
||||||
return new SpannedString("");
|
return new SpannedString("");
|
||||||
}
|
}
|
||||||
|
@ -43,6 +46,14 @@ public class SpannedTypeAdapter implements JsonDeserializer<Spanned>, JsonSerial
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public JsonElement serialize(Spanned src, Type typeOfSrc, JsonSerializationContext context) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package com.keylesspalace.tusky.repository
|
package com.keylesspalace.tusky.repository
|
||||||
|
|
||||||
import android.text.SpannedString
|
import android.text.SpannedString
|
||||||
|
import androidx.core.text.parseAsHtml
|
||||||
|
import androidx.core.text.toHtml
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
import com.keylesspalace.tusky.db.*
|
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.DISK
|
||||||
import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK
|
import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK
|
||||||
import com.keylesspalace.tusky.util.Either
|
import com.keylesspalace.tusky.util.Either
|
||||||
import com.keylesspalace.tusky.util.HtmlConverter
|
|
||||||
import com.keylesspalace.tusky.util.dec
|
import com.keylesspalace.tusky.util.dec
|
||||||
import com.keylesspalace.tusky.util.inc
|
import com.keylesspalace.tusky.util.inc
|
||||||
import io.reactivex.Single
|
import io.reactivex.Single
|
||||||
|
@ -40,8 +41,7 @@ class TimelineRepositoryImpl(
|
||||||
private val timelineDao: TimelineDao,
|
private val timelineDao: TimelineDao,
|
||||||
private val mastodonApi: MastodonApi,
|
private val mastodonApi: MastodonApi,
|
||||||
private val accountManager: AccountManager,
|
private val accountManager: AccountManager,
|
||||||
private val gson: Gson,
|
private val gson: Gson
|
||||||
private val htmlConverter: HtmlConverter
|
|
||||||
) : TimelineRepository {
|
) : TimelineRepository {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
@ -150,7 +150,7 @@ class TimelineRepositoryImpl(
|
||||||
|
|
||||||
for (status in statuses) {
|
for (status in statuses) {
|
||||||
timelineDao.insertInTransaction(
|
timelineDao.insertInTransaction(
|
||||||
status.toEntity(accountId, htmlConverter, gson),
|
status.toEntity(accountId, gson),
|
||||||
status.account.toEntity(accountId, gson),
|
status.account.toEntity(accountId, gson),
|
||||||
status.reblog?.account?.toEntity(accountId, gson)
|
status.reblog?.account?.toEntity(accountId, gson)
|
||||||
)
|
)
|
||||||
|
@ -214,7 +214,7 @@ class TimelineRepositoryImpl(
|
||||||
inReplyToId = status.inReplyToId,
|
inReplyToId = status.inReplyToId,
|
||||||
inReplyToAccountId = status.inReplyToAccountId,
|
inReplyToAccountId = status.inReplyToAccountId,
|
||||||
reblog = null,
|
reblog = null,
|
||||||
content = status.content?.let(htmlConverter::fromHtml) ?: SpannedString(""),
|
content = status.content?.parseAsHtml() ?: SpannedString(""),
|
||||||
createdAt = Date(status.createdAt),
|
createdAt = Date(status.createdAt),
|
||||||
emojis = emojis,
|
emojis = emojis,
|
||||||
reblogsCount = status.reblogsCount,
|
reblogsCount = status.reblogsCount,
|
||||||
|
@ -269,7 +269,7 @@ class TimelineRepositoryImpl(
|
||||||
inReplyToId = status.inReplyToId,
|
inReplyToId = status.inReplyToId,
|
||||||
inReplyToAccountId = status.inReplyToAccountId,
|
inReplyToAccountId = status.inReplyToAccountId,
|
||||||
reblog = null,
|
reblog = null,
|
||||||
content = status.content?.let(htmlConverter::fromHtml) ?: SpannedString(""),
|
content = status.content?.parseAsHtml() ?: SpannedString(""),
|
||||||
createdAt = Date(status.createdAt),
|
createdAt = Date(status.createdAt),
|
||||||
emojis = emojis,
|
emojis = emojis,
|
||||||
reblogsCount = status.reblogsCount,
|
reblogsCount = status.reblogsCount,
|
||||||
|
@ -362,7 +362,6 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Status.toEntity(timelineUserId: Long,
|
fun Status.toEntity(timelineUserId: Long,
|
||||||
htmlConverter: HtmlConverter,
|
|
||||||
gson: Gson): TimelineStatusEntity {
|
gson: Gson): TimelineStatusEntity {
|
||||||
val actionable = actionableStatus
|
val actionable = actionableStatus
|
||||||
return TimelineStatusEntity(
|
return TimelineStatusEntity(
|
||||||
|
@ -372,7 +371,7 @@ fun Status.toEntity(timelineUserId: Long,
|
||||||
authorServerId = actionable.account.id,
|
authorServerId = actionable.account.id,
|
||||||
inReplyToId = actionable.inReplyToId,
|
inReplyToId = actionable.inReplyToId,
|
||||||
inReplyToAccountId = actionable.inReplyToAccountId,
|
inReplyToAccountId = actionable.inReplyToAccountId,
|
||||||
content = htmlConverter.toHtml(actionable.content),
|
content = actionable.content.toHtml(),
|
||||||
createdAt = actionable.createdAt.time,
|
createdAt = actionable.createdAt.time,
|
||||||
emojis = actionable.emojis.let(gson::toJson),
|
emojis = actionable.emojis.let(gson::toJson),
|
||||||
reblogsCount = actionable.reblogsCount,
|
reblogsCount = actionable.reblogsCount,
|
||||||
|
@ -385,7 +384,7 @@ fun Status.toEntity(timelineUserId: Long,
|
||||||
visibility = actionable.visibility,
|
visibility = actionable.visibility,
|
||||||
attachments = actionable.attachments.let(gson::toJson),
|
attachments = actionable.attachments.let(gson::toJson),
|
||||||
mentions = actionable.mentions.let(gson::toJson),
|
mentions = actionable.mentions.let(gson::toJson),
|
||||||
application = actionable.let(gson::toJson),
|
application = actionable.application.let(gson::toJson),
|
||||||
reblogServerId = reblog?.id,
|
reblogServerId = reblog?.id,
|
||||||
reblogAccountId = reblog?.let { this.account.id },
|
reblogAccountId = reblog?.let { this.account.id },
|
||||||
poll = actionable.poll.let(gson::toJson),
|
poll = actionable.poll.let(gson::toJson),
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -171,15 +171,12 @@ class StatusViewHelper(private val itemView: View) {
|
||||||
sensitiveMediaWarning.visibility = View.GONE
|
sensitiveMediaWarning.visibility = View.GONE
|
||||||
sensitiveMediaShow.visibility = View.GONE
|
sensitiveMediaShow.visibility = View.GONE
|
||||||
} else {
|
} else {
|
||||||
|
sensitiveMediaWarning.text = if (sensitive) {
|
||||||
val hiddenContentText: String = if (sensitive) {
|
|
||||||
context.getString(R.string.status_sensitive_media_title)
|
context.getString(R.string.status_sensitive_media_title)
|
||||||
} else {
|
} else {
|
||||||
context.getString(R.string.status_media_hidden_title)
|
context.getString(R.string.status_media_hidden_title)
|
||||||
}
|
}
|
||||||
|
|
||||||
sensitiveMediaWarning.text = HtmlUtils.fromHtml(hiddenContentText)
|
|
||||||
|
|
||||||
sensitiveMediaWarning.visibility = if (showingContent) View.GONE else View.VISIBLE
|
sensitiveMediaWarning.visibility = if (showingContent) View.GONE else View.VISIBLE
|
||||||
sensitiveMediaShow.visibility = if (showingContent) View.VISIBLE else View.GONE
|
sensitiveMediaShow.visibility = if (showingContent) View.VISIBLE else View.GONE
|
||||||
sensitiveMediaShow.setOnClickListener { v ->
|
sensitiveMediaShow.setOnClickListener { v ->
|
||||||
|
|
|
@ -18,10 +18,10 @@ package com.keylesspalace.tusky.viewdata
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
|
import androidx.core.text.parseAsHtml
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.entity.Poll
|
import com.keylesspalace.tusky.entity.Poll
|
||||||
import com.keylesspalace.tusky.entity.PollOption
|
import com.keylesspalace.tusky.entity.PollOption
|
||||||
import com.keylesspalace.tusky.util.HtmlUtils
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.roundToInt
|
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 {
|
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(" ")
|
||||||
.append(title)
|
.append(title)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package com.keylesspalace.tusky.fragment
|
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.google.gson.Gson
|
||||||
import com.keylesspalace.tusky.SpanUtilsTest
|
import com.keylesspalace.tusky.SpanUtilsTest
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
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.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.repository.*
|
import com.keylesspalace.tusky.repository.*
|
||||||
import com.keylesspalace.tusky.util.Either
|
import com.keylesspalace.tusky.util.Either
|
||||||
import com.keylesspalace.tusky.util.HtmlConverter
|
|
||||||
import com.nhaarman.mockitokotlin2.isNull
|
import com.nhaarman.mockitokotlin2.isNull
|
||||||
import com.nhaarman.mockitokotlin2.verify
|
import com.nhaarman.mockitokotlin2.verify
|
||||||
import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
|
import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
|
||||||
|
@ -24,14 +25,18 @@ import io.reactivex.schedulers.TestScheduler
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
import org.mockito.ArgumentMatchers.any
|
import org.mockito.ArgumentMatchers.any
|
||||||
import org.mockito.ArgumentMatchers.anyInt
|
import org.mockito.ArgumentMatchers.anyInt
|
||||||
import org.mockito.Mock
|
import org.mockito.Mock
|
||||||
import org.mockito.MockitoAnnotations
|
import org.mockito.MockitoAnnotations
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.collections.ArrayList
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
|
@Config(sdk = [28])
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
class TimelineRepositoryTest {
|
class TimelineRepositoryTest {
|
||||||
@Mock
|
@Mock
|
||||||
lateinit var timelineDao: TimelineDao
|
lateinit var timelineDao: TimelineDao
|
||||||
|
@ -56,15 +61,6 @@ class TimelineRepositoryTest {
|
||||||
domain = "domain.com",
|
domain = "domain.com",
|
||||||
isActive = true
|
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
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
|
@ -74,8 +70,7 @@ class TimelineRepositoryTest {
|
||||||
gson = Gson()
|
gson = Gson()
|
||||||
testScheduler = TestScheduler()
|
testScheduler = TestScheduler()
|
||||||
RxJavaPlugins.setIoSchedulerHandler { testScheduler }
|
RxJavaPlugins.setIoSchedulerHandler { testScheduler }
|
||||||
subject = TimelineRepositoryImpl(timelineDao, mastodonApi, accountManager, gson,
|
subject = TimelineRepositoryImpl(timelineDao, mastodonApi, accountManager, gson)
|
||||||
htmlConverter)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -97,7 +92,7 @@ class TimelineRepositoryTest {
|
||||||
verify(timelineDao).insertStatusIfNotThere(Placeholder("1").toEntity(account.id))
|
verify(timelineDao).insertStatusIfNotThere(Placeholder("1").toEntity(account.id))
|
||||||
for (status in statuses) {
|
for (status in statuses) {
|
||||||
verify(timelineDao).insertInTransaction(
|
verify(timelineDao).insertInTransaction(
|
||||||
status.toEntity(account.id, htmlConverter, gson),
|
status.toEntity(account.id, gson),
|
||||||
status.account.toEntity(account.id, gson),
|
status.account.toEntity(account.id, gson),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
@ -129,7 +124,7 @@ class TimelineRepositoryTest {
|
||||||
// We assume for now that overlapped one is inserted but it's not that important
|
// We assume for now that overlapped one is inserted but it's not that important
|
||||||
for (status in response) {
|
for (status in response) {
|
||||||
verify(timelineDao).insertInTransaction(
|
verify(timelineDao).insertInTransaction(
|
||||||
status.toEntity(account.id, htmlConverter, gson),
|
status.toEntity(account.id, gson),
|
||||||
status.account.toEntity(account.id, gson),
|
status.account.toEntity(account.id, gson),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
@ -159,7 +154,7 @@ class TimelineRepositoryTest {
|
||||||
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
|
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
|
||||||
for (status in response) {
|
for (status in response) {
|
||||||
verify(timelineDao).insertInTransaction(
|
verify(timelineDao).insertInTransaction(
|
||||||
status.toEntity(account.id, htmlConverter, gson),
|
status.toEntity(account.id, gson),
|
||||||
status.account.toEntity(account.id, gson),
|
status.account.toEntity(account.id, gson),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
@ -201,7 +196,7 @@ class TimelineRepositoryTest {
|
||||||
// We assume for now that overlapped one is inserted but it's not that important
|
// We assume for now that overlapped one is inserted but it's not that important
|
||||||
for (status in response) {
|
for (status in response) {
|
||||||
verify(timelineDao).insertInTransaction(
|
verify(timelineDao).insertInTransaction(
|
||||||
status.toEntity(account.id, htmlConverter, gson),
|
status.toEntity(account.id, gson),
|
||||||
status.account.toEntity(account.id, gson),
|
status.account.toEntity(account.id, gson),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
@ -246,7 +241,7 @@ class TimelineRepositoryTest {
|
||||||
|
|
||||||
for (status in response) {
|
for (status in response) {
|
||||||
verify(timelineDao).insertInTransaction(
|
verify(timelineDao).insertInTransaction(
|
||||||
status.toEntity(account.id, htmlConverter, gson),
|
status.toEntity(account.id, gson),
|
||||||
status.account.toEntity(account.id, gson),
|
status.account.toEntity(account.id, gson),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
@ -263,7 +258,7 @@ class TimelineRepositoryTest {
|
||||||
val status = makeStatus("2")
|
val status = makeStatus("2")
|
||||||
val dbStatus = makeStatus("1")
|
val dbStatus = makeStatus("1")
|
||||||
val dbResult = TimelineStatusWithAccount()
|
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)
|
dbResult.account = status.account.toEntity(account.id, gson)
|
||||||
|
|
||||||
whenever(mastodonApi.homeTimelineSingle(any(), any(), any()))
|
whenever(mastodonApi.homeTimelineSingle(any(), any(), any()))
|
||||||
|
@ -297,7 +292,7 @@ class TimelineRepositoryTest {
|
||||||
return Status(
|
return Status(
|
||||||
id = id,
|
id = id,
|
||||||
account = account,
|
account = account,
|
||||||
content = SpanUtilsTest.FakeSpannable("hello$id"),
|
content = SpannableString("hello$id"),
|
||||||
createdAt = Date(),
|
createdAt = Date(),
|
||||||
emojis = listOf(),
|
emojis = listOf(),
|
||||||
reblogsCount = 3,
|
reblogsCount = 3,
|
||||||
|
@ -328,7 +323,7 @@ class TimelineRepositoryTest {
|
||||||
localUsername = "test$id",
|
localUsername = "test$id",
|
||||||
username = "test$id@example.com",
|
username = "test$id@example.com",
|
||||||
displayName = "Example Account $id",
|
displayName = "Example Account $id",
|
||||||
note = SpanUtilsTest.FakeSpannable("Note! $id"),
|
note = SpannableString("Note! $id"),
|
||||||
url = "https://example.com/@test$id",
|
url = "https://example.com/@test$id",
|
||||||
avatar = "avatar$id",
|
avatar = "avatar$id",
|
||||||
header = "Header$id",
|
header = "Header$id",
|
||||||
|
|
Loading…
Reference in a new issue