/* * Twidere - Twitter client for Android * * Copyright (C) 2012-2017 Mariotaku Lee * * 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 . */ package org.mariotaku.twidere.extension.model.api import android.text.Spanned import android.text.style.URLSpan import org.apache.commons.text.translate.LookupTranslator import org.mariotaku.commons.text.CodePointArray import org.mariotaku.ktextension.isNotNullOrEmpty import org.mariotaku.ktextension.mapToArray import org.mariotaku.microblog.library.twitter.model.EntitySupport import org.mariotaku.microblog.library.twitter.model.ExtendedEntitySupport import org.mariotaku.microblog.library.twitter.model.MediaEntity import org.mariotaku.microblog.library.twitter.model.Status import org.mariotaku.twidere.exception.MalformedResponseException import org.mariotaku.twidere.extension.model.addFilterFlag import org.mariotaku.twidere.extension.model.toParcelable import org.mariotaku.twidere.extension.model.updateContentFilterInfo import org.mariotaku.twidere.extension.model.updateFilterInfo import org.mariotaku.twidere.extension.toSpanItem import org.mariotaku.twidere.model.* import org.mariotaku.twidere.model.util.ParcelableLocationUtils import org.mariotaku.twidere.model.util.ParcelableMediaUtils import org.mariotaku.twidere.text.AcctMentionSpan import org.mariotaku.twidere.text.HashtagSpan import org.mariotaku.twidere.util.EntityArrays import org.mariotaku.twidere.util.HtmlBuilder import org.mariotaku.twidere.util.HtmlSpanBuilder import org.mariotaku.twidere.util.InternalTwitterContentUtils import org.mariotaku.twidere.util.InternalTwitterContentUtils.getMediaUrl import org.mariotaku.twidere.util.InternalTwitterContentUtils.getStartEndForEntity import kotlin.math.max fun Status.toParcelable(details: AccountDetails, profileImageSize: String = "normal", updateFilterInfoAction: (Status, ParcelableStatus) -> Unit = ::updateFilterInfoDefault): ParcelableStatus { return toParcelable(details.key, details.type, profileImageSize, updateFilterInfoAction).apply { account_color = details.color } } fun Status.toParcelable(accountKey: UserKey, accountType: String, profileImageSize: String = "normal", updateFilterInfoAction: (Status, ParcelableStatus) -> Unit = ::updateFilterInfoDefault): ParcelableStatus { val result = ParcelableStatus() applyTo(accountKey, accountType, profileImageSize, result, updateFilterInfoAction) return result } fun Status.applyTo(accountKey: UserKey, accountType: String, profileImageSize: String = "normal", result: ParcelableStatus, updateFilterInfoAction: (Status, ParcelableStatus) -> Unit = ::updateFilterInfoDefault) { val extras = ParcelableStatus.Extras() result.account_key = accountKey result.id = id result.sort_id = sortId result.timestamp = createdAt?.time ?: 0 extras.external_url = inferredExternalUrl extras.entities_url = entities?.urls?.map { it.expandedUrl }?.let { if (isQuoteStatus) { it.take(max(0, it.count() - 1)) } else { it } }?.toTypedArray() extras.support_entities = entities != null extras.statusnet_conversation_id = statusnetConversationId extras.conversation_id = conversationId result.is_pinned_status = user.pinnedTweetIds?.contains(id) ?: false val retweetedStatus = retweetedStatus result.is_retweet = isRetweet result.retweeted = wasRetweeted() val status: Status if (retweetedStatus != null) { status = retweetedStatus val retweetUser = user result.retweet_id = retweetedStatus.id result.retweet_timestamp = retweetedStatus.createdAt?.time ?: 0 result.retweeted_by_user_key = retweetUser.key result.retweeted_by_user_name = retweetUser.name result.retweeted_by_user_screen_name = retweetUser.screenName result.retweeted_by_user_profile_image = retweetUser.getProfileImageOfSize(profileImageSize) extras.retweeted_external_url = retweetedStatus.inferredExternalUrl if (retweetUser.isBlocking == true) { result.addFilterFlag(ParcelableStatus.FilterFlags.BLOCKING_USER) } if (retweetUser.isBlockedBy == true) { result.addFilterFlag(ParcelableStatus.FilterFlags.BLOCKED_BY_USER) } if (retweetedStatus.isPossiblySensitive) { result.addFilterFlag(ParcelableStatus.FilterFlags.POSSIBLY_SENSITIVE) } } else { status = this if (status.isPossiblySensitive) { result.addFilterFlag(ParcelableStatus.FilterFlags.POSSIBLY_SENSITIVE) } } val quoted = status.quotedStatus result.is_quote = status.isQuoteStatus result.quoted_id = status.quotedStatusId if (quoted != null) { val quotedUser = quoted.user ?: throw MalformedResponseException() result.quoted_id = quoted.id extras.quoted_external_url = quoted.inferredExternalUrl val quotedText = quoted.htmlText // Twitter will escape <> to <>, so if a status contains those symbols unescaped // We should treat this as an html if (quotedText.isHtml) { val html = HtmlSpanBuilder.fromHtml(quotedText, quoted.extendedText) result.quoted_text_unescaped = html?.toString() result.quoted_text_plain = result.quoted_text_unescaped result.quoted_spans = html?.spanItems } else { val textWithIndices = quoted.formattedTextWithIndices() result.quoted_text_plain = quotedText.twitterUnescaped() result.quoted_text_unescaped = textWithIndices.text result.quoted_spans = textWithIndices.spans extras.quoted_display_text_range = textWithIndices.range } result.quoted_timestamp = quoted.createdAt.time result.quoted_source = quoted.source result.quoted_media = ParcelableMediaUtils.fromStatus(quoted, accountKey, accountType) result.quoted_user_key = quotedUser.key result.quoted_user_name = quotedUser.name result.quoted_user_screen_name = quotedUser.screenName result.quoted_user_profile_image = quotedUser.getProfileImageOfSize(profileImageSize) result.quoted_user_is_protected = quotedUser.isProtected result.quoted_user_is_verified = quotedUser.isVerified if (quoted.isPossiblySensitive) { result.addFilterFlag(ParcelableStatus.FilterFlags.POSSIBLY_SENSITIVE) } } else if (status.isQuoteStatus) { result.addFilterFlag(ParcelableStatus.FilterFlags.QUOTE_NOT_AVAILABLE) } result.reply_count = status.replyCount result.retweet_count = status.retweetCount result.favorite_count = status.favoriteCount result.in_reply_to_name = status.inReplyToName result.in_reply_to_screen_name = status.inReplyToScreenName result.in_reply_to_status_id = status.inReplyToStatusId result.in_reply_to_user_key = status.getInReplyToUserKey(accountKey) val user = status.user ?: throw MalformedResponseException() result.user_key = user.key result.user_name = user.name result.user_screen_name = user.screenName result.user_profile_image_url = user.getProfileImageOfSize(profileImageSize) result.user_is_protected = user.isProtected result.user_is_verified = user.isVerified result.user_is_following = user.isFollowing == true extras.user_statusnet_profile_url = user.statusnetProfileUrl extras.user_profile_image_url_fallback = user.profileImageUrlHttps ?: user.profileImageUrl val text = status.htmlText // Twitter will escape <> to <>, so if a status contains those symbols unescaped // We should treat this as an html if (text.isHtml) { val html = HtmlSpanBuilder.fromHtml(text, status.extendedText) result.text_unescaped = html?.toString() result.text_plain = result.text_unescaped result.spans = html?.spanItems } else { val textWithIndices = status.formattedTextWithIndices() result.text_unescaped = textWithIndices.text result.text_plain = text.twitterUnescaped() result.spans = textWithIndices.spans extras.display_text_range = textWithIndices.range } result.media = ParcelableMediaUtils.fromStatus(status, accountKey, accountType) result.source = status.source result.location = status.parcelableLocation result.is_favorite = status.isFavorited if (result.account_key.maybeEquals(result.retweeted_by_user_key)) { result.my_retweet_id = result.id } else { result.my_retweet_id = status.currentUserRetweet } result.is_possibly_sensitive = status.isPossiblySensitive result.mentions = status.userMentionEntities?.mapToArray { it.toParcelable(user.host) } result.card = status.card?.toParcelable(accountKey, accountType) result.card_name = result.card?.name result.place_full_name = status.placeFullName result.lang = status.lang result.extras = extras if (result.media.isNotNullOrEmpty() || result.quoted_media.isNotNullOrEmpty()) { result.addFilterFlag(ParcelableStatus.FilterFlags.HAS_MEDIA) } updateFilterInfoAction(this, result) } fun Status.formattedTextWithIndices(): StatusTextWithIndices { val source = CodePointArray(this.fullText ?: this.text!!) val builder = HtmlBuilder(source, throwExceptions = false, sourceIsEscaped = true, shouldReEscape = false ) builder.addEntities(this) val textWithIndices = StatusTextWithIndices() val (text, spans) = builder.buildWithIndices() textWithIndices.text = text textWithIndices.spans = spans // Display text range val range = displayTextRange?.takeIf { it.size == 2 } if (range != null) { textWithIndices.range = intArrayOf( source.findResultRangeLength(spans, 0, range[0]), text.length - source.findResultRangeLength(spans, range[1], source.length()) ) } return textWithIndices } fun CodePointArray.findResultRangeLength(spans: Array, origStart: Int, origEnd: Int): Int { val findResult = findByOrigRange(spans, origStart, origEnd) if (findResult.isEmpty()) { return charCount(origStart, origEnd) } val first = findResult.first() val last = findResult.last() if (first.orig_start == -1 || last.orig_end == -1) return charCount(origStart, origEnd) return charCount(origStart, first.orig_start) + (last.end - first.start) + charCount(first.orig_end, origEnd) } fun HtmlBuilder.addEntities(entities: EntitySupport) { // Format media. var mediaEntities: Array? = null if (entities is ExtendedEntitySupport) { mediaEntities = entities.extendedMediaEntities } if (mediaEntities == null) { mediaEntities = entities.mediaEntities } val startEnd = IntArray(2) mediaEntities?.forEach { mediaEntity -> val mediaUrl = getMediaUrl(mediaEntity) if (mediaUrl != null && getStartEndForEntity(mediaEntity, startEnd)) { addLink(mediaEntity.expandedUrl, mediaEntity.displayUrl, startEnd[0], startEnd[1], false) } } entities.urlEntities?.forEach { urlEntity -> val expandedUrl = urlEntity.expandedUrl if (expandedUrl != null && getStartEndForEntity(urlEntity, startEnd)) { addLink(expandedUrl, urlEntity.displayUrl, startEnd[0], startEnd[1], false) } } } fun updateFilterInfoDefault(status: Status, result: ParcelableStatus) { result.updateFilterInfo(setOf( status.userDescriptionUnescaped, status.userUrlExpanded, status.userLocation, status.retweetedStatus?.userDescriptionUnescaped, status.retweetedStatus?.userLocation, status.retweetedStatus?.userUrlExpanded, status.quotedStatus?.userDescriptionUnescaped, status.quotedStatus?.userLocation, status.quotedStatus?.userUrlExpanded )) } /** * Ignores status user info */ fun updateFilterInfoForUserTimeline(status: Status, result: ParcelableStatus) { result.updateContentFilterInfo() if (result.is_retweet) { result.filter_users = setOf(result.user_key, result.quoted_user_key).filterNotNull().toTypedArray() result.filter_names = setOf(result.user_name, result.quoted_user_name).filterNotNull().toTypedArray() result.filter_descriptions = setOf( status.retweetedStatus?.userDescriptionUnescaped, status.retweetedStatus?.userUrlExpanded, status.retweetedStatus?.userLocation, status.quotedStatus?.userDescriptionUnescaped, status.quotedStatus?.userLocation, status.quotedStatus?.userUrlExpanded ).filterNotNull().joinToString("\n") } else { result.filter_users = setOf(result.quoted_user_key).filterNotNull().toTypedArray() result.filter_names = setOf(result.quoted_user_name).filterNotNull().toTypedArray() result.filter_descriptions = setOf( status.quotedStatus?.userDescriptionUnescaped, status.quotedStatus?.userLocation, status.quotedStatus?.userUrlExpanded ).filterNotNull().joinToString("\n") } } private fun String.twitterUnescaped(): String { return TwitterRawTextTranslator.translate(this) } private inline val Status.userDescriptionUnescaped: String? get() = user?.let { InternalTwitterContentUtils.formatUserDescription(it)?.first } private inline val Status.userUrlExpanded: String? get() = user?.urlEntities?.firstOrNull()?.expandedUrl private inline val Status.userLocation: String? get() = user?.location /** * @param spans Ordered spans * * * @param start orig_start * * * @param end orig_end */ internal fun findByOrigRange(spans: Array, start: Int, end: Int): List { return spans.filter { it.orig_start >= start && it.orig_end <= end } } internal inline val CharSequence.spanItems get() = (this as? Spanned)?.let { text -> text.getSpans(0, length, URLSpan::class.java).mapToArray { val item = it.toSpanItem(text) when (it) { is AcctMentionSpan -> item.type = SpanItem.SpanType.ACCT_MENTION is HashtagSpan -> item.type = SpanItem.SpanType.HASHTAG } return@mapToArray item } } internal inline val String.isHtml get() = contains('<') && contains('>') private inline val Status.inReplyToName get() = userMentionEntities?.firstOrNull { inReplyToUserId == it.id }?.name ?: attentions?.firstOrNull { inReplyToUserId == it.id }?.fullName ?: inReplyToScreenName private inline val Status.placeFullName get() = place?.fullName ?: location?.takeIf { ParcelableLocation.valueOf(location) == null } private inline val Status.inferredExternalUrl get() = externalUrl ?: uri?.let { uri -> noticeUriRegex.matchEntire(uri)?.let { result: MatchResult -> "https://${result.groups[1]?.value}/notice/${result.groups[3]?.value}" } } private val Status.parcelableLocation: ParcelableLocation? get() { val geoLocation = geoLocation if (geoLocation != null) { return ParcelableLocationUtils.fromGeoLocation(geoLocation) } val locationString = location ?: return null val location = ParcelableLocation.valueOf(locationString) if (location != null) { return location } return null } private fun Status.getInReplyToUserKey(accountKey: UserKey): UserKey? { val inReplyToUserId = inReplyToUserId ?: return null val entities = userMentionEntities if (entities != null) { if (entities.any { inReplyToUserId == it.id }) { return UserKey(inReplyToUserId, accountKey.host) } } val attentions = attentions if (attentions != null) { attentions.firstOrNull { inReplyToUserId == it.id }?.let { val host = getUserHost(it.ostatusUri, accountKey.host) return UserKey(inReplyToUserId, host) } } return UserKey(inReplyToUserId, accountKey.host) } private val noticeUriRegex = Regex("tag:([\\w\\d.]+),(\\d{4}-\\d{2}-\\d{2}):noticeId=(\\d+):objectType=(\\w+)") private object TwitterRawTextTranslator : LookupTranslator(EntityArrays.BASIC_UNESCAPE) class StatusTextWithIndices { var text: String? = null var spans: Array? = null var range: IntArray? = null }