SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt

668 lines
20 KiB
Kotlin
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package jp.juggler.subwaytooter.api.entity
import android.content.Context
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.widget.TextView
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.Pref
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.MisskeyAccountDetailMap
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.UserRelation
import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.subwaytooter.util.NetworkEmojiInvalidator
import jp.juggler.subwaytooter.view.MyLinkMovementMethod
import jp.juggler.util.*
import java.net.IDN
import java.util.*
import java.util.regex.Pattern
open class TootAccount(parser : TootParser, src : JsonObject) {
//URL of the user's profile page (can be remote)
// https://mastodon.juggler.jp/@tateisu
// 疑似アカウントではnullになります
val url : String?
// The ID of the account
val id : EntityId
// Equals username for local users, includes @domain for remote ones
val acctAscii : String // punycode
val prettyAcct : String // unicode
// The username of the account /[A-Za-z0-9_]{1,30}/
val username : String
val hostAscii : String // punycode
// The account's display name
val display_name : String
//Boolean for when the account cannot be followed without waiting for approval first
val locked : Boolean
// The time the account was created
// ex: "2017-04-13T11:06:08.289Z"
val created_at : String?
val time_created_at : Long
// The number of followers for the account
var followers_count : Long? = null
//The number of accounts the given account is following
var following_count : Long? = null
// The number of statuses the account has made
var statuses_count : Long? = null
// Biography of user
// 説明文。改行は\r\n。リンクなどはHTMLタグで書かれている
val note : String?
// URL to the avatar image
val avatar : String?
// URL to the avatar static image (gif)
val avatar_static : String?
//URL to the header image
val header : String?
// URL to the header static image (gif)
val header_static : String?
val source : Source?
val profile_emojis : HashMap<String, NicoProfileEmoji>?
val movedRef : TootAccountRef?
val moved : TootAccount?
get() = movedRef?.get()
class Field(
val name : String,
val value : String,
val verified_at : Long // 0L if not verified
)
val fields : ArrayList<Field>?
val custom_emojis : HashMap<String, CustomEmoji>?
val bot : Boolean
val isCat : Boolean
val isAdmin : Boolean
val isPro : Boolean
val isLocal :Boolean
get() = !acctAscii.contains('@')
val isRemote :Boolean
get() = acctAscii.contains('@')
// user_hides_network is preference, not exposed in API
// val user_hides_network : Boolean
var pinnedNotes : ArrayList<TootStatus>? = null
private var pinnedNoteIds : ArrayList<String>? = null
// misskey (only /api/users/show)
var location : String? = null
var birthday : String? = null
// mastodon 3.0.0-dev
// last_status_at : "2019-08-29T12:42:08.838Z" or null
private var last_status_at = 0L
val json : JsonObject
init {
this.json = src
var sv : String?
if(parser.serviceType == ServiceType.MISSKEY) {
val remoteHost = src.string("host")
val tmpHost = (remoteHost ?: parser.accessHost ?: error("missing host")).toLowerCase(Locale.JAPAN)
this.hostAscii = IDN.toASCII(tmpHost,IDN.ALLOW_UNASSIGNED)
val prettyHost = IDN.toUnicode(tmpHost,IDN.ALLOW_UNASSIGNED)
this.custom_emojis =
parseMapOrNull(CustomEmoji.decodeMisskey, src.jsonArray("emojis"))
this.profile_emojis = null
this.username = src.notEmptyOrThrow("username")
this.url = "https://$hostAscii/@$username"
//
sv = src.string("name")
this.display_name = if(sv?.isNotEmpty() == true) sv.sanitizeBDI() else username
//
this.note = src.string("description")
this.source = null
this.movedRef = null
this.locked = src.optBoolean("isLocked")
this.bot = src.optBoolean("isBot", false)
this.isCat = src.optBoolean("isCat", false)
this.isAdmin = src.optBoolean("isAdmin", false)
this.isPro = src.optBoolean("isPro", false)
// this.user_hides_network = src.optBoolean("user_hides_network")
this.id = EntityId.mayDefault(src.string("id"))
this.acctAscii = when {
// アクセス元から見て内部ユーザなら short acct
remoteHost?.equals(
parser.accessHost,
ignoreCase = true
) != false -> username
// アクセス元から見て外部ユーザならfull acct
else -> "$username@$hostAscii"
}
this.prettyAcct = when {
// アクセス元から見て内部ユーザなら short acct
remoteHost?.equals(
parser.accessHost,
ignoreCase = true
) != false -> username
// アクセス元から見て外部ユーザならfull acct
else -> "$username@$prettyHost"
}
this.followers_count = src.long("followersCount") ?: - 1L
this.following_count = src.long("followingCount") ?: - 1L
this.statuses_count = src.long("notesCount") ?: - 1L
this.created_at = src.string("createdAt")
this.time_created_at = TootStatus.parseTime(this.created_at)
this.avatar = src.string("avatarUrl")
this.avatar_static = src.string("avatarUrl")
this.header = src.string("bannerUrl")
this.header_static = src.string("bannerUrl")
this.pinnedNoteIds = src.stringArrayList("pinnedNoteIds")
if(parser.misskeyDecodeProfilePin) {
val list = parseList(::TootStatus, parser, src.jsonArray("pinnedNotes"))
list.forEach { it.pinned = true }
this.pinnedNotes = if(list.isNotEmpty()) list else null
}
val profile = src.jsonObject("profile")
this.location = profile?.string("location")
this.birthday = profile?.string("birthday")
this.fields = parseMisskeyFields(src)
UserRelation.fromAccount(parser, src, id)
@Suppress("LeakingThis")
MisskeyAccountDetailMap.fromAccount(parser, this, id)
} else {
// 絵文字データは先に読んでおく
this.custom_emojis = parseMapOrNull(CustomEmoji.decode, src.jsonArray("emojis"))
this.profile_emojis = when(val o = src["profile_emojis"]) {
is JsonArray -> parseMapOrNull(::NicoProfileEmoji, o, TootStatus.log)
is JsonObject -> parseProfileEmoji2(::NicoProfileEmoji, o, TootStatus.log)
else -> null
}
// 疑似アカウントにacctとusernameだけ
this.url = src.string("url")
this.username = src.notEmptyOrThrow("username")
//
sv = src.string("display_name")
this.display_name = if(sv?.isNotEmpty() == true) sv.sanitizeBDI() else username
//
this.note = src.string("note")
this.source = parseSource(src.jsonObject("source"))
this.movedRef = TootAccountRef.mayNull(
parser,
src.jsonObject("moved")?.let {
TootAccount(parser, it)
}
)
this.locked = src.optBoolean("locked")
this.fields = parseFields(src.jsonArray("fields"))
this.bot = src.optBoolean("bot", false)
this.isAdmin = false
this.isCat = false
this.isPro = false
// this.user_hides_network = src.optBoolean("user_hides_network")
this.last_status_at = TootStatus.parseTime(src.string("last_status_at"))
when(parser.serviceType) {
ServiceType.MASTODON -> {
val hostAccess = parser.accessHost
this.id = EntityId.mayDefault(src.string("id"))
val tmpAcct = src.notEmptyOrThrow("acct")
val tmpHost = (
findHostFromUrl(tmpAcct, hostAccess, url)
?: throw RuntimeException("can't get host from acct or url")
).toLowerCase(Locale.JAPAN)
this.hostAscii = IDN.toASCII(tmpHost,IDN.ALLOW_UNASSIGNED)
val prettyHost = IDN.toUnicode(tmpHost,IDN.ALLOW_UNASSIGNED)
this.acctAscii = if( !tmpAcct.contains('@') ) tmpAcct else "$username@$hostAscii"
this.prettyAcct = if( !tmpAcct.contains('@') ) tmpAcct else "$username@$prettyHost"
this.followers_count = src.long("followers_count")
this.following_count = src.long("following_count")
this.statuses_count = src.long("statuses_count")
this.created_at = src.string("created_at")
this.time_created_at = TootStatus.parseTime(this.created_at)
this.avatar = src.string("avatar")
this.avatar_static = src.string("avatar_static")
this.header = src.string("header")
this.header_static = src.string("header_static")
}
ServiceType.TOOTSEARCH -> {
// tootsearch のアカウントのIDはどのタンス上のものか分からないので役に立たない
this.id = EntityId.DEFAULT
sv = src.notEmptyOrThrow("acct")
val tmpHost = (findHostFromUrl(sv, null, url)
?: throw RuntimeException("can't get host from acct or url")
).toLowerCase(Locale.JAPAN)
this.hostAscii = IDN.toASCII(tmpHost,IDN.ALLOW_UNASSIGNED)
val prettyHost=IDN.toUnicode(tmpHost,IDN.ALLOW_UNASSIGNED)
this.acctAscii = this.username + "@" + this.hostAscii
this.prettyAcct = this.username + "@" + prettyHost
this.followers_count = src.long("followers_count")
this.following_count = src.long("following_count")
this.statuses_count = src.long("statuses_count")
this.created_at = src.string("created_at")
this.time_created_at = TootStatus.parseTime(this.created_at)
this.avatar = src.string("avatar")
this.avatar_static = src.string("avatar_static")
this.header = src.string("header")
this.header_static = src.string("header_static")
}
ServiceType.MSP -> {
this.id = EntityId.mayDefault(src.string("id"))
// MSPはLTLの情報しか持ってないのでacctは常にホスト名部分を持たない
val tmpHost = (findHostFromUrl(null, null, url)
?: throw RuntimeException("can't get host from url")
).toLowerCase(Locale.JAPAN)
this.hostAscii = IDN.toASCII(tmpHost,IDN.ALLOW_UNASSIGNED)
val prettyHost = IDN.toUnicode(tmpHost,IDN.ALLOW_UNASSIGNED)
this.acctAscii = this.username + "@" + hostAscii
this.prettyAcct = this.username + "@" + prettyHost
this.followers_count = null
this.following_count = null
this.statuses_count = null
this.created_at = null
this.time_created_at = 0L
val avatar = src.string("avatar")
this.avatar = avatar
this.avatar_static = avatar
this.header = null
this.header_static = null
}
ServiceType.MISSKEY -> error("will not happen")
}
}
}
class Source(src : JsonObject) {
// デフォルト公開範囲
val privacy : String?
// 添付画像をデフォルトでNSFWにする設定
private val sensitive : Boolean
// HTMLエンコードされていない、生のnote
val note : String?
// 2.4.0 から?
val fields : ArrayList<Field>?
init {
this.privacy = src.string("privacy")
this.note = src.string("note")
// nullになることがあるが、falseと同じ扱いでよい
this.sensitive = src.optBoolean("sensitive", false)
//
this.fields = parseFields(src.jsonArray("fields"))
}
}
// リストメンバーダイアログや引っ越し先ユーザなど、TL以外の部分に名前を表示する場合は
// Invalidator の都合でSpannableを別途生成する必要がある
fun decodeDisplayName(context : Context) : Spannable {
// remove white spaces
val sv = reWhitespace.matcher(display_name).replaceAll(" ")
// decode emoji code
return DecodeOptions(
context,
emojiMapProfile = profile_emojis,
emojiMapCustom = custom_emojis
).decodeEmoji(sv)
}
private fun SpannableStringBuilder.replaceAllEx(
pattern : Pattern,
replacement : String
) : SpannableStringBuilder {
val m = pattern.matcher(this)
var buffer : SpannableStringBuilder? = null
var lastEnd = 0
while(m.find()) {
val dst = buffer ?: SpannableStringBuilder().also { buffer = it }
dst
.append(this.subSequence(lastEnd, m.start()))
.append(replacement) // 変数展開には未対応
lastEnd = m.end()
}
return buffer
?.also { if(lastEnd < length) it.append(subSequence(lastEnd, length)) }
?: this
}
private fun SpannableStringBuilder.trimEx(isSpace : (c : Char) -> Boolean = { it <= ' ' }) : CharSequence {
var start = 0
var end = length
while(start < end && isSpace(this[start])) ++ start
while(end > start && isSpace(this[end - 1])) -- end
return when {
start >= end -> ""
start == 0 && end == length -> this
else -> subSequence(start, end)
}
}
fun setAccountExtra(
accessInfo : SavedAccount,
tv : TextView,
invalidator : NetworkEmojiInvalidator?,
fromProfileHeader : Boolean = false
) : SpannableStringBuilder? {
val pref = App1.pref
val context = tv.context
var sb : SpannableStringBuilder? = null
fun prepareSb() = sb?.apply { append('\n') } ?: SpannableStringBuilder().also { sb = it }
val delm = ": "
if(Pref.bpDirectoryLastActive(pref) && last_status_at > 0L)
prepareSb()
.append(context.getString(R.string.last_active))
.append(delm)
.append(TootStatus.formatTime(context, last_status_at, true))
if(! fromProfileHeader) {
if(Pref.bpDirectoryTootCount(pref)
&& (statuses_count ?: 0L) > 0L)
prepareSb()
.append(context.getString(R.string.toot_count))
.append(delm)
.append(statuses_count.toString())
if(Pref.bpDirectoryFollowers(pref)
&& !Pref.bpHideFollowCount(pref)
&& (followers_count ?: 0L) > 0L)
prepareSb()
.append(context.getString(R.string.followers))
.append(delm)
.append(followers_count.toString())
if(Pref.bpDirectoryNote(pref) && note?.isNotEmpty() == true) {
val decodedNote = DecodeOptions(
context,
accessInfo,
short = true,
decodeEmoji = true,
emojiMapProfile = profile_emojis,
emojiMapCustom = custom_emojis,
unwrapEmojiImageTag = true
).decodeHTML(note)
.replaceAllEx(reNoteLineFeed, " ")
.trimEx()
if(decodedNote.isNotBlank()) {
prepareSb().append(
if(decodedNote is SpannableStringBuilder && decodedNote.length > 200) {
decodedNote.replace(200, decodedNote.length, "")
} else {
decodedNote
}
)
}
}
}
tv.vg(sb != null)
?.apply {
text = sb
movementMethod = MyLinkMovementMethod
invalidator?.register(sb)
}
?: invalidator?.clear()
return sb
}
companion object {
private val log = LogCategory("TootAccount")
internal val reWhitespace : Pattern = Pattern.compile("[\\s\\t\\x0d\\x0a]+")
// noteをディレクトリに表示する際、制御文字や空白を変換する
private val reNoteLineFeed : Pattern = Pattern.compile("""[\x00-\x20\x7f ]+""")
// MFMのメンション @username @username@host
// (Mastodonのカラムでは使われていない)
internal val reMention = Pattern.compile("""\A@(\w+(?:[\w-]*\w)?)(?:@(\w[\w.-]*\w))?""")
internal val reUrlHost : Pattern =
Pattern.compile("""\Ahttps://(\w[\w.-]*\w)/""")
// host, user ,(instance)
// Misskeyだけではないのでusernameの定義が違う
internal val reAccountUrl : Pattern =
Pattern.compile("""\Ahttps://(\w[\w.-]*\w)/@(\w+[\w-]*)(?:@(\w[\w.-]*\w))?(?=\z|[?#])""")
// host,user
internal val reAccountUrl2 : Pattern =
Pattern.compile("""\Ahttps://(\w[\w.-]*\w)/users/(\w|\w+[\w-]*\w)(?=\z|[?#])""")
fun getAcctFromUrl(url : String?) : String? {
url ?: return null
var m = reAccountUrl.matcher(url)
if(m.find()) {
val host = m.groupEx(1)
val user = m.groupEx(2)?.decodePercent()
val instance = m.groupEx(3)?.decodePercent()
return if(instance?.isNotEmpty() == true) {
"$user@$instance"
} else {
"$user@$host"
}
}
m = reAccountUrl2.matcher(url)
if(m.find()) {
val host = m.groupEx(1)
val user = m.groupEx(2)?.decodePercent()
return "$user@$host"
}
return null
}
private fun parseSource(src : JsonObject?) : Source? {
src ?: return null
return try {
Source(src)
} catch(ex : Throwable) {
log.trace(ex)
log.e("parseSource failed.")
null
}
}
// Tootsearch用。URLやUriを使ってアカウントのインスタンス名を調べる
fun findHostFromUrl(acct : String?, accessHost : String?, url : String?) : String? {
// acctから調べる
if(acct != null) {
val pos = acct.indexOf('@')
if(pos != - 1) {
val host = acct.substring(pos + 1)
if(host.isNotEmpty()) return host.toLowerCase(Locale.JAPAN)
}
}
// accessHostから調べる
if(accessHost != null) {
return accessHost
}
// URLから調べる
// たぶんどんなURLでもauthorityの部分にホスト名が来るだろう(慢心)
url.mayUri()?.authority?.let { host ->
if(host.isNotEmpty()) {
return host.toLowerCase(Locale.JAPAN)
}
}
log.e("findHostFromUrl: can't parse host from URL $url")
return null
}
fun parseFields(src : JsonArray?) : ArrayList<Field>? {
src ?: return null
val dst = ArrayList<Field>()
for(item in src) {
if(item !is JsonObject) continue
val name = item.string("name") ?: continue
val value = item.string("value") ?: continue
val verifiedAt = when(val svVerifiedAt = item.string("verified_at")) {
null -> 0L
else -> TootStatus.parseTime(svVerifiedAt)
}
dst.add(Field(name, value, verifiedAt))
}
return dst.notEmpty()
}
private fun parseMisskeyFields(src : JsonObject) : ArrayList<Field>? {
var dst : ArrayList<Field>? = null
// リモートユーザーはAP経由のフィールドが表示される
// https://github.com/syuilo/misskey/pull/3590
// https://github.com/syuilo/misskey/pull/3596
src.jsonArray("fields")?.forEach { o ->
if(o !is JsonObject) return@forEach
//plain text
val n = o.string("name") ?: return@forEach
// mfm
val v = o.string("value") ?: ""
dst = (dst ?: ArrayList()).apply { add(Field(n, v, 0L)) }
}
// misskeyローカルユーザーはTwitter等の連携をフィールドに表示する
// https://github.com/syuilo/misskey/pull/3499
// https://github.com/syuilo/misskey/pull/3586
fun appendField(name : String, caption : String, url : String) {
val value = """[$caption]($url)"""
dst = (dst ?: ArrayList()).apply { add(Field(name, value, 0L)) }
}
runCatching {
src.jsonObject("twitter")?.let {
appendField(
"Twitter",
"@${it.string("screenName")}",
"https://twitter.com/intent/user?user_id=${it.string("userId")}"
)
}
}
runCatching {
src.jsonObject("github")?.string("login")?.let {
appendField(
"GitHub",
it,
"https://github.com/$it"
)
}
}
runCatching {
src.jsonObject("discord")?.let {
appendField(
"Discord",
"${it.string("username")}#${it.string("discriminator")}",
"https://discordapp.com/users/${it.string("id")}"
)
}
}
return if(dst?.isNotEmpty() == true) dst else null
}
fun acctAndPrettyAcct(src : String) : Pair<String, String> {
val cols = src.split("@")
if(cols.size < 2 ) return Pair(src,src)
val username = cols[0]
return Pair(
"$username@${IDN.toASCII(cols[1], IDN.ALLOW_UNASSIGNED)}",
"$username@${IDN.toUnicode(cols[1], IDN.ALLOW_UNASSIGNED)}"
)
}
}
}