Mastodon 4.0のフィルタAPI v2 を使ってみる。detektAllタスクで複数モジュールをまとめて検査する

This commit is contained in:
tateisu 2023-01-14 19:19:01 +09:00
parent 301905c016
commit a51b45d0cd
36 changed files with 805 additions and 393 deletions

View File

@ -1,5 +1,4 @@
import io.gitlab.arturbosch.detekt.Detekt
import io.gitlab.arturbosch.detekt.DetektPlugin
import java.text.SimpleDateFormat
@ -7,8 +6,9 @@ apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
apply plugin: "io.gitlab.arturbosch.detekt"
apply plugin: 'com.google.gms.google-services'
apply plugin: "io.gitlab.arturbosch.detekt"
android {
@ -144,9 +144,6 @@ dependencies {
exclude group: 'com.android.support', module: 'support-annotations'
})
detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:$detekt_version")
detektPlugins("io.gitlab.arturbosch.detekt:detekt-cli:$detekt_version")
implementation project(':colorpicker')
implementation project(':emoji')
implementation project(':apng_android')
@ -287,18 +284,24 @@ dependencies {
// LiveEvent
implementation "com.github.hadilq:live-event:1.3.0"
detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:$detekt_version")
}
repositories {
mavenCentral()
}
detekt {
// relative path from module path.
basePath = projectDir
// detekt
def projectSource = file(projectDir)
def configFile = files("$rootDir/config/detekt/config.yml")
def baselineFile = file("$rootDir/config/detekt/baseline.xml")
def kotlinFiles = "**/*.kt"
def resourceFiles = "**/resources/**"
def buildFiles = "**/build/**"
// preconfigure defaults
buildUponDefaultConfig = true
tasks.register("detektAll", Detekt) {
description = "Custom DETEKT build for all modules"
// activate all available (even unstable) rules.
allRules = false
@ -306,21 +309,32 @@ detekt {
// a way of suppressing issues before introducing detekt
// baseline = file("$rootDir/config/detekt/baseline.xml")
// point to your custom config defining rules to run, overwriting default behavior
config = files("$rootDir/config/detekt/config.yml")
}
parallel = true
ignoreFailures = false
autoCorrect = false
plugins.withType(DetektPlugin) {
tasks.withType(Detekt) {
reports {
xml.required.set(true)
xml.outputLocation = file("$buildDir/reports/detekt/st-${name}.xml")
html.required.set(true)
html.outputLocation = file("$buildDir/reports/detekt/st-${name}.html")
txt.required.set(true)
txt.outputLocation = file("$buildDir/reports/detekt/st-${name}.txt")
sarif.required.set(true)
sarif.outputLocation = file("$buildDir/reports/detekt/st-${name}.sarif")
}
// preconfigure defaults
buildUponDefaultConfig = true
setSource(projectSource)
config.setFrom(configFile)
if (baselineFile.isFile()) {
baseline.set(baselineFile)
}
include(kotlinFiles)
exclude(resourceFiles, buildFiles)
reports {
html.enabled = true
xml.enabled = false
txt.enabled = false
xml.required.set(true)
xml.outputLocation = file("$buildDir/reports/detekt/st-${name}.xml")
html.required.set(true)
html.outputLocation = file("$buildDir/reports/detekt/st-${name}.html")
txt.required.set(true)
txt.outputLocation = file("$buildDir/reports/detekt/st-${name}.txt")
sarif.required.set(true)
sarif.outputLocation = file("$buildDir/reports/detekt/st-${name}.sarif")
}
}

View File

@ -2,7 +2,7 @@ package jp.juggler.subwaytooter
import androidx.test.runner.AndroidJUnit4
import jp.juggler.subwaytooter.api.entity.TootAccount
import jp.juggler.util.asciiPatternString
import jp.juggler.util.data.asciiPatternString
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@ -35,7 +35,6 @@ class TestMisskeyMentionAndroid {
// IDEで警告が出るが、Androidは正規表現エンジンが異なるので仕方ない
@Suppress("RegExpRedundantNestedCharacterClass")
assertEquals(true, """[[ ]]][ ]""".toRegex().matches(" ] "))
}
@Test
@ -56,7 +55,6 @@ class TestMisskeyMentionAndroid {
// エスケープ文字の後に何もない場合も素通しする
assertEquals("""\""", """\""".asciiPatternString())
}
@Test
@ -96,5 +94,4 @@ class TestMisskeyMentionAndroid {
findMention("@tateisu@xn--3-pfuzbe6htf.juggler.jp")
)
}
}

View File

@ -1,7 +1,7 @@
package jp.juggler.subwaytooter
import androidx.test.runner.AndroidJUnit4
import jp.juggler.util.CharacterGroup
import jp.juggler.util.data.CharacterGroup
import jp.juggler.util.data.WordTrieTree
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@ -16,7 +16,6 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class WordTrieTreeTest {
companion object {
private val whitespace_chars = charArrayOf(
@ -63,7 +62,6 @@ class WordTrieTreeTest {
@Throws(Exception::class)
fun testCharacterGroupTokenizer() {
val whitespace = String(whitespace_chars)
val whitespace_len = whitespace.length
var id: Int
@ -209,5 +207,4 @@ class WordTrieTreeTest {
assertEquals(33, strTest.length.toLong()) // 末尾の空白はマッチ範囲には含まれない
}
}
}

View File

@ -4,8 +4,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.AndroidJUnit4
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.JsonObject
import jp.juggler.util.jsonObject
import jp.juggler.util.data.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
@ -28,9 +27,9 @@ class TestDuplicateMap {
accountJson: JsonObject,
statusId: String,
uri: String,
url: String?
url: String?,
): TootStatus {
val itemJson = jsonObject {
val itemJson = buildJsonObject {
put("account", accountJson)
put("id", statusId)
put("uri", uri)
@ -48,7 +47,7 @@ class TestDuplicateMap {
accountJson: JsonObject,
statusId: String,
uri: String,
url: String?
url: String?,
) {
val item = genStatus(parser, accountJson, statusId, uri, url)
assertNotNull(item)
@ -57,8 +56,7 @@ class TestDuplicateMap {
assertEquals(true, map.isDuplicate(item))
}
val account1Json = jsonObject {
val account1Json = buildJsonObject {
put("username", "user1")
put("acct", "user1")
put("id", 1L)
@ -110,13 +108,12 @@ class TestDuplicateMap {
return generatedItems
}
private fun testDuplicateNotification(): ArrayList<TimelineItem> {
val generatedItems = ArrayList<TimelineItem>()
fun checkNotification(
map: DuplicateMap,
parser: TootParser,
id: String
id: String,
) {
val itemJson = JsonObject()
@ -144,7 +141,7 @@ class TestDuplicateMap {
val generatedItems = ArrayList<TimelineItem>()
fun checkReport(
map: DuplicateMap,
id: String
id: String,
) {
val item = TootReport(JsonObject().apply {
put("id", id)
@ -157,7 +154,6 @@ class TestDuplicateMap {
assertEquals(true, map.isDuplicate(item))
}
val map = DuplicateMap()
checkReport(map, "r0")
checkReport(map, "r1")
@ -166,13 +162,12 @@ class TestDuplicateMap {
return generatedItems
}
private fun testDuplicateAccount(): ArrayList<TimelineItem> {
val generatedItems = ArrayList<TimelineItem>()
fun checkAccount(
map: DuplicateMap,
parser: TootParser,
id: String
id: String,
) {
val itemJson = JsonObject()

View File

@ -8,10 +8,11 @@ import jp.juggler.subwaytooter.api.entity.Host
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.SimpleHttpClient
import jp.juggler.util.JsonObject
import jp.juggler.util.MEDIA_TYPE_JSON
import jp.juggler.util.jsonArray
import jp.juggler.util.jsonObject
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.buildJsonArray
import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.log.LogCategory
import jp.juggler.util.network.MEDIA_TYPE_JSON
import kotlinx.coroutines.runBlocking
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
@ -27,12 +28,15 @@ import java.util.concurrent.atomic.AtomicReference
@Suppress("MemberVisibilityCanPrivate")
@RunWith(AndroidJUnit4::class)
class TestTootApiClient {
companion object {
private val log = LogCategory("TestTootApiClient")
}
private val appContext = InstrumentationRegistry.getInstrumentation().targetContext!!
class SimpleHttpClientMock(
private val responseGenerator: (request: Request) -> Response,
val webSocketGenerator: (request: Request, ws_listener: WebSocketListener) -> WebSocket
val webSocketGenerator: (request: Request, ws_listener: WebSocketListener) -> WebSocket,
) : SimpleHttpClient {
override var onCallCreated: (Call) -> Unit = {}
@ -41,14 +45,14 @@ class TestTootApiClient {
override suspend fun getResponse(
request: Request,
tmpOkhttpClient: OkHttpClient?
tmpOkhttpClient: OkHttpClient?,
): Response {
return responseGenerator(request)
}
override fun getWebSocket(
request: Request,
webSocketListener: WebSocketListener
webSocketListener: WebSocketListener,
): WebSocket {
return webSocketGenerator(request, webSocketListener)
}
@ -67,7 +71,12 @@ class TestTootApiClient {
"応答の解析中…"
)
}
private fun assertReading(callback: ProgressRecordTootApiCallback,path:String){
private fun assertReading(
callback: ProgressRecordTootApiCallback,
@Suppress("SameParameterValue")
path: String,
) {
assertOneOf(
callback.progressString,
"Reading: GET $path",
@ -82,7 +91,7 @@ class TestTootApiClient {
copyBody.writeTo(buffer)
return buffer.readUtf8()
} catch (ex: Throwable) {
ex.printStackTrace()
log.e(ex, "requestBodyString failed.")
return null
}
}
@ -133,7 +142,6 @@ class TestTootApiClient {
)
)
.build()
}
// アクセストークンの作成
bodyString?.contains("grant_type=authorization_code") == true -> {
@ -201,16 +209,15 @@ class TestTootApiClient {
put("url", "http://$instance/@$username")
}
val array = jsonArray {
val array = buildJsonArray {
for (i in 0 until 10) {
add(jsonObject {
add(buildJsonObject {
put("account", account1Json)
put("id", i.toLong())
put("uri", "https://$instance/@$username/$i")
put("url", "https://$instance/@$username/$i")
})
}
}
Response.Builder()
@ -231,7 +238,6 @@ class TestTootApiClient {
.body(request.url.toString().toResponseBody(mediaTypeTextPlain))
.build()
}
},
webSocketGenerator = { request: Request, _: WebSocketListener ->
@ -265,8 +271,9 @@ class TestTootApiClient {
var progressString: String? = null
override val isApiCancelled: Boolean
get() = cancelled
override suspend fun isApiCancelled(): Boolean {
return cancelled
}
override suspend fun publishApiProgress(s: String) {
progressString = s
@ -522,11 +529,10 @@ class TestTootApiClient {
appContext,
httpClient = createHttpClientNotImplemented(),
callback = object : TootApiCallback {
override val isApiCancelled: Boolean
get() {
++flag
return true
}
override suspend fun isApiCancelled(): Boolean {
++flag
return true
}
override suspend fun publishApiProgress(s: String) {
++flag
@ -540,7 +546,7 @@ class TestTootApiClient {
}
}
)
val isApiCancelled = client.isApiCancelled
val isApiCancelled = client.isApiCancelled()
client.publishApiProgress("testing")
client.publishApiProgressRatio(50, 100)
assertEquals(3, flag)
@ -548,7 +554,6 @@ class TestTootApiClient {
assertEquals("testing", progressString)
assertEquals(50, progressValue)
assertEquals(100, progressMax)
}
}
@ -604,7 +609,6 @@ class TestTootApiClient {
"instance: 通信エラー。: NotImplementedError An operation is not implemented.",
)
assertNull(result.response)
}
// progressPath を指定したらpublishApiProgressに渡されること
@ -683,7 +687,7 @@ class TestTootApiClient {
val bodyString = client.readBodyString(result)
assertEquals(null, bodyString)
assertEquals(null, result.bodyString)
assertReading(callback,"instance")
assertReading(callback, "instance")
assertEquals("Error! (HTTP 500 status-message) instance", result.error)
assertNull(result.data)
}
@ -698,7 +702,7 @@ class TestTootApiClient {
val bodyString = client.readBodyString(result)
assertEquals("", bodyString)
assertEquals("", result.bodyString)
assertReading(callback,"instance")
assertReading(callback, "instance")
assertEquals(null, result.error)
assertNull(result.data)
}
@ -713,7 +717,7 @@ class TestTootApiClient {
val bodyString = client.readBodyString(result)
assertEquals("", bodyString)
assertEquals("", result.bodyString)
assertReading(callback,"instance")
assertReading(callback, "instance")
assertEquals(null, result.error)
assertNull(result.data)
}
@ -806,7 +810,7 @@ class TestTootApiClient {
assertNotNull(r2)
assertEquals(null, result.string)
assertEquals(null, result.bodyString)
assertReading(callback,"instance")
assertReading(callback, "instance")
assertEquals("Error! (HTTP 500 status-message) instance", result.error)
}
@ -821,7 +825,7 @@ class TestTootApiClient {
assertNotNull(r2)
assertEquals(null, result.string)
assertEquals(null, result.bodyString)
assertReading(callback,"instance")
assertReading(callback, "instance")
assertEquals("(no information) (HTTP 404 status-message) instance", result.error)
assertNull(result.data)
}
@ -837,7 +841,7 @@ class TestTootApiClient {
assertNotNull(r2)
assertEquals("", result.string)
assertEquals("", result.bodyString)
assertReading(callback,"instance")
assertReading(callback, "instance")
assertEquals(null, result.error)
assertEquals("", result.data)
}
@ -852,11 +856,10 @@ class TestTootApiClient {
assertNotNull(r2)
assertEquals(null, result.string)
assertEquals(null, result.bodyString)
assertReading(callback,"instance")
assertReading(callback, "instance")
assertEquals("(no information) (HTTP 200 status-message) instance", result.error)
assertNull(result.data)
}
}
}
@ -927,7 +930,7 @@ class TestTootApiClient {
assertNotNull(r2)
assertEquals(null, result.data)
assertEquals(null, result.bodyString)
assertReading(callback,"instance")
assertReading(callback, "instance")
assertEquals("Error! (HTTP 500 status-message) instance", result.error)
}
@ -942,7 +945,7 @@ class TestTootApiClient {
assertNotNull(r2)
assertEquals(0, result.jsonObject?.size)
assertEquals("", result.bodyString)
assertReading(callback,"instance")
assertReading(callback, "instance")
assertEquals(null, result.error)
}
@ -956,7 +959,7 @@ class TestTootApiClient {
assertNotNull(r2)
assertEquals(0, result.jsonObject?.size)
assertEquals("", result.bodyString)
assertReading(callback,"instance")
assertReading(callback, "instance")
assertEquals(null, result.error)
}
@ -970,7 +973,7 @@ class TestTootApiClient {
assertNotNull(r2)
assertEquals(null, result.data)
assertEquals(null, result.bodyString)
assertReading(callback,"instance")
assertReading(callback, "instance")
assertEquals("(no information) (HTTP 200 status-message) instance", result.error)
assertNull(result.data)
}
@ -1186,7 +1189,7 @@ class TestTootApiClient {
@Test
fun testWebSocket() {
runBlocking {
val tokenInfo = jsonObject {
val tokenInfo = buildJsonObject {
put("access_token", "DUMMY_ACCESS_TOKEN")
}
@ -1211,4 +1214,3 @@ class TestTootApiClient {
}
}
}

View File

@ -1,13 +1,10 @@
package jp.juggler.subwaytooter.api.entity
import android.test.mock.MockContext
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.AndroidJUnit4
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.JsonArray
import jp.juggler.util.JsonObject
import jp.juggler.util.decodeJsonObject
import jp.juggler.util.notEmptyOrThrow
import jp.juggler.util.data.*
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
@ -91,7 +88,6 @@ class TestEntityUtils {
// error
src.add("""{"s":"","l":"100"}""".decodeJsonObject())
assertEquals(2, parseList(::TestEntity, src).size)
}
@Test
@ -110,7 +106,6 @@ class TestEntityUtils {
// error
src.add("""{"s":"","l":"100"}""".decodeJsonObject())
assertEquals(2, parseListOrNull(::TestEntity, src)?.size)
}
@Test
@ -129,7 +124,6 @@ class TestEntityUtils {
// error
src.add("""{"s":"","l":"100"}""".decodeJsonObject())
assertEquals(2, parseMap(::TestEntity, src).size)
}
@Test
@ -148,10 +142,14 @@ class TestEntityUtils {
// error
src.add("""{"s":"","l":"100"}""".decodeJsonObject())
assertEquals(2, parseMapOrNull(::TestEntity, src)?.size)
}
private val parser = TootParser(MockContext(), SavedAccount.na)
private val parser by lazy {
TootParser(
InstrumentationRegistry.getInstrumentation().targetContext,
SavedAccount.na,
)
}
@Test
fun testParseItemWithParser() {
@ -214,7 +212,6 @@ class TestEntityUtils {
// error
src.add("""{"s":"","l":"100"}""".decodeJsonObject())
assertEquals(2, parseList(::TestEntity, parser, src).size)
}
@Test
@ -233,7 +230,6 @@ class TestEntityUtils {
// error
src.add("""{"s":"","l":"100"}""".decodeJsonObject())
assertEquals(2, parseListOrNull(::TestEntity, parser, src)?.size)
}
@Test(expected = RuntimeException::class)

View File

@ -5,7 +5,6 @@ import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.AndroidJUnit4
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.global.DB_VERSION
import jp.juggler.subwaytooter.global.TABLE_LIST
import org.junit.Assert.assertNull

View File

@ -1,6 +1,5 @@
package jp.juggler.subwaytooter.util
import androidx.test.runner.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Test

View File

@ -3,39 +3,41 @@ package jp.juggler.subwaytooter
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.*
import android.widget.ArrayAdapter
import android.widget.CheckBox
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.children
import jp.juggler.subwaytooter.api.ApiPath
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.TootFilter
import jp.juggler.subwaytooter.api.entity.TootFilterContext
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.databinding.ActKeywordFilterBinding
import jp.juggler.subwaytooter.databinding.LvKeywordFilterBinding
import jp.juggler.subwaytooter.table.AcctColor
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.JsonArray
import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.notEmpty
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.network.toPostRequestBuilder
import jp.juggler.util.network.toPut
import jp.juggler.util.network.toRequestBody
class ActKeywordFilter
: AppCompatActivity(), View.OnClickListener {
class ActKeywordFilter : AppCompatActivity() {
companion object {
internal val log = LogCategory("ActKeywordFilter")
private val log = LogCategory("ActKeywordFilter")
internal const val EXTRA_ACCOUNT_DB_ID = "account_db_id"
internal const val EXTRA_FILTER_ID = "filter_id"
internal const val EXTRA_INITIAL_PHRASE = "initial_phrase"
private const val EXTRA_ACCOUNT_DB_ID = "account_db_id"
private const val EXTRA_FILTER_ID = "filter_id"
private const val EXTRA_INITIAL_PHRASE = "initial_phrase"
private const val STATE_EXPIRE_SPINNER = "expire_spinner"
private const val STATE_EXPIRE_AT = "expire_at"
private const val STATE_KEYWORDS = "keywords"
private const val STATE_DELETE_IDS = "deleteIds"
fun open(
activity: Activity,
@ -50,9 +52,6 @@ class ActKeywordFilter
activity.startActivity(intent)
}
internal const val STATE_EXPIRE_SPINNER = "expire_spinner"
internal const val STATE_EXPIRE_AT = "expire_at"
private val expire_duration_list = intArrayOf(
-1, // dont change
0, // unlimited
@ -89,6 +88,8 @@ class ActKeywordFilter
private var filterId: EntityId? = null
private var filterExpire: Long = 0L
private val deleteIds = HashSet<String>()
///////////////////////////////////////////////////
override fun onCreate(savedInstanceState: Bundle?) {
@ -115,13 +116,28 @@ class ActKeywordFilter
if (filterId != null) {
startLoading()
} else {
views. spExpire.setSelection(1)
views. etPhrase.setText(intent.getStringExtra(EXTRA_INITIAL_PHRASE) ?: "")
views.spExpire.setSelection(1)
val initialText = intent.getStringExtra(EXTRA_INITIAL_PHRASE)?.trim() ?: ""
views.etTitle.setText(initialText)
addKeywordArea(TootFilterKeyword(keyword = initialText))
}
} else {
savedInstanceState.getStringArrayList(STATE_DELETE_IDS)
?.let { deleteIds.addAll(it) }
savedInstanceState.getStringArrayList(STATE_KEYWORDS)
?.mapNotNull { it?.decodeJsonObject() }
?.forEach {
try {
addKeywordArea(TootFilterKeyword(it))
} catch (ex: Throwable) {
log.e(ex, "can't decode TootFilterKeyword")
}
}
val iv = savedInstanceState.getInt(STATE_EXPIRE_SPINNER, -1)
if (iv != -1) {
views. spExpire.setSelection(iv)
views.spExpire.setSelection(iv)
}
filterExpire = savedInstanceState.getLong(STATE_EXPIRE_AT, filterExpire)
}
@ -132,6 +148,13 @@ class ActKeywordFilter
if (!loading) {
outState.putInt(STATE_EXPIRE_SPINNER, views.spExpire.selectedItemPosition)
outState.putLong(STATE_EXPIRE_AT, filterExpire)
outState.putStringArrayList(STATE_DELETE_IDS, ArrayList<String>(deleteIds))
views.llKeywords.children
.mapNotNull { (it.tag as? VhKeyword)?.encodeJson()?.toString() }
.toList()
.let { outState.putStringArrayList(STATE_KEYWORDS, ArrayList<String>(it)) }
}
}
@ -149,19 +172,17 @@ class ActKeywordFilter
fixHorizontalPadding(findViewById(R.id.svContent))
// tvAccount = findViewById(R.id.tvAccount)
// etPhrase = findViewById(R.id.etPhrase)
// cbContextHome = findViewById(R.id.cbContextHome)
// cbContextNotification = findViewById(R.id.cbContextNotification)
// cbContextPublic = findViewById(R.id.cbContextPublic)
// cbContextThread = findViewById(R.id.cbContextThread)
// cbContextProfile = findViewById(R.id.cbContextProfile)
// cbFilterIrreversible = findViewById(R.id.cbFilterIrreversible)
// cbFilterWordMatch = findViewById(R.id.cbFilterWordMatch)
// tvExpire = findViewById(R.id.tvExpire)
// spExpire = findViewById(R.id.spExpire)
views.btnSave.setOnClickListener(this)
views.btnSave.setOnClickListener { save() }
views.btnAddKeyword.setOnClickListener {
val ti = TootInstance.getCached(account)
when {
ti == null ->
showToast(true, "can't get server information")
!ti.versionGE(TootInstance.VERSION_4_0_0) && views.llKeywords.childCount >= 1 ->
showToast(true, "before mastodon 4.0, allowed 1 keyword per 1 filter.")
else -> addKeywordArea(TootFilterKeyword(keyword = ""))
}
}
val captionList = arrayOf(
getString(R.string.dont_change),
@ -184,16 +205,35 @@ class ActKeywordFilter
private fun startLoading() {
loading = true
launchMain {
var resultFilter: TootFilter? = null
runApiTask(account) { client ->
client.request("${ApiPath.PATH_FILTERS}/$filterId")
?.also { result ->
result.jsonObject?.let {
// try v2
var result = client.request("${ApiPath.PATH_FILTERS_V2}/$filterId")
result?.jsonObject?.let {
try {
resultFilter = TootFilter(it)
return@runApiTask result
} catch (ex: Throwable) {
log.e(ex, "parse error.")
}
}
if (result?.response?.code == 404) {
// try v1
result = client.request("${ApiPath.PATH_FILTERS}/$filterId")
result?.jsonObject?.let {
try {
resultFilter = TootFilter(it)
return@runApiTask result
} catch (ex: Throwable) {
log.e(ex, "parse error.")
}
}
}
result
}?.let { result ->
loading = false
when (val filter = resultFilter) {
@ -219,9 +259,17 @@ class ActKeywordFilter
setContextChecked(filter, views.cbContextThread, TootFilterContext.Thread)
setContextChecked(filter, views.cbContextProfile, TootFilterContext.Account)
views.etPhrase.setText(filter.phrase)
views.cbFilterIrreversible.isChecked = filter.irreversible
views.cbFilterWordMatch.isChecked = filter.whole_word
views.rgAction.check(if (filter.hide) views.rbHide.id else views.rbWarn.id)
if (filter.keywords.isEmpty()) {
filter.keywords = listOf(TootFilterKeyword(keyword = ""))
}
filter.keywords.forEach { addKeywordArea(it) }
views.etTitle.setText(
filter.title.notEmpty() ?: filter.keywords.firstOrNull()?.keyword ?: ""
)
views.tvExpire.text = if (filter.time_expires_at == 0L) {
getString(R.string.filter_expire_unlimited)
@ -230,92 +278,180 @@ class ActKeywordFilter
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.btnSave -> save()
}
}
private fun setContextChecked(filter: TootFilter, cb: CheckBox, fc: TootFilterContext) {
cb.isChecked = filter.hasContext(fc)
}
private fun JsonArray.putContextChecked(cb: CheckBox, fc:TootFilterContext) {
if (cb.isChecked) add(fc.apiName)
}
private fun save() {
if (loading) return
val params = buildJsonObject {
val vhList = views.llKeywords.children.mapNotNull { it.tag as? VhKeyword }.toList()
if (vhList.isEmpty() || vhList.any { it.keyword.isEmpty() }) {
showToast(true, R.string.filter_keyword_empty)
return
}
put("context", JsonArray().apply {
putContextChecked(views.cbContextHome, TootFilterContext.Home)
putContextChecked(views.cbContextNotification, TootFilterContext.Notifications)
putContextChecked(views.cbContextPublic, TootFilterContext.Public)
putContextChecked(views.cbContextThread, TootFilterContext.Thread)
putContextChecked(views.cbContextProfile, TootFilterContext.Account)
})
put("phrase", views.etPhrase.text.toString())
put("irreversible",views. cbFilterIrreversible.isChecked)
put("whole_word", views.cbFilterWordMatch.isChecked)
var seconds = -1
val i = views.spExpire.selectedItemPosition
if (i >= 0 && i < expire_duration_list.size) {
seconds = expire_duration_list[i]
}
when (seconds) {
// dont change
-1 -> {
}
// unlimited
0 -> when {
// already unlimited. don't change.
filterExpire <= 0L -> {
}
// XXX: currently there is no way to remove expires from existing filter.
else -> put("expires_in", Int.MAX_VALUE)
}
// set seconds
else -> put("expires_in", seconds)
}
val title = views.etTitle.text.toString().trim()
if (title.isEmpty()) {
showToast(true, R.string.filter_title_empty)
return
}
launchMain {
runApiTask(account) { client ->
if (filterId == null) {
client.request(
ApiPath.PATH_FILTERS,
params.toPostRequestBuilder()
)
} else {
client.request(
"${ApiPath.PATH_FILTERS}/$filterId",
params.toRequestBody().toPut()
)
}
}?.let { result ->
val error = result.error
if (error != null) {
showToast(true, result.error)
} else {
val appState = App1.prepare(applicationContext, "ActKeywordFilter.save()")
for (column in appState.columnList) {
if (column.type == ColumnType.KEYWORD_FILTER && column.accessInfo == account) {
column.filterReloadRequired = true
}
var result = saveV2(vhList)
if (result?.response?.code == 404) {
result = saveV1(vhList)
}
result ?: return@launchMain // cancelled
val error = result.error
if (error != null) {
showToast(true, result.error)
} else {
val appState = App1.prepare(applicationContext, "ActKeywordFilter.save()")
for (column in appState.columnList) {
if (column.type == ColumnType.KEYWORD_FILTER && column.accessInfo == account) {
column.filterReloadRequired = true
}
finish()
}
finish()
}
}
}
private fun filterParamBase() = buildJsonObject {
fun JsonArray.putContextChecked(cb: CheckBox, fc: TootFilterContext) {
if (cb.isChecked) add(fc.apiName)
}
put("context", JsonArray().apply {
putContextChecked(views.cbContextHome, TootFilterContext.Home)
putContextChecked(views.cbContextNotification, TootFilterContext.Notifications)
putContextChecked(views.cbContextPublic, TootFilterContext.Public)
putContextChecked(views.cbContextThread, TootFilterContext.Thread)
putContextChecked(views.cbContextProfile, TootFilterContext.Account)
})
when (val seconds = expire_duration_list
.elementAtOrNull(views.spExpire.selectedItemPosition)
?: -1
) {
// dont change
-1 -> Unit
// unlimited
0 -> when {
// already unlimited. don't change.
filterExpire <= 0L -> Unit
// XXX: currently there is no way to remove expires from existing filter.
else -> put("expires_in", Int.MAX_VALUE)
}
// set seconds
else -> put("expires_in", seconds)
}
}
private suspend fun saveV1(vhList: List<VhKeyword>): TootApiResult? {
if (vhList.size != 1) return TootApiResult("V1 API allow only 1 keyword.")
val params = filterParamBase().apply {
put("irreversible", views.rgAction.checkedRadioButtonId == views.rbHide.id)
val vh = vhList.first()
put("phrase", vh.keyword)
put("whole_word", vh.wholeWord)
}
return runApiTask(account) { client ->
if (filterId == null) {
client.request(
ApiPath.PATH_FILTERS,
params.toPostRequestBuilder()
)
} else {
client.request(
"${ApiPath.PATH_FILTERS}/$filterId",
params.toRequestBody().toPut()
)
}
}
}
private suspend fun saveV2(vhList: List<VhKeyword>): TootApiResult? {
val params = filterParamBase().apply {
put(
"filter_action", when {
views.rbHide.isChecked -> "hide"
else -> "warn"
}
)
put("keywords_attributes", buildJsonArray {
vhList.forEach { vh ->
add(buildJsonObject {
put("keyword", vh.keyword)
put("whole_word", vh.wholeWord)
vh.id?.let { put("id", it) }
})
}
deleteIds.forEach { id ->
add(buildJsonObject {
put("id", id)
put("_destroy", id)
})
}
})
}
return runApiTask(account) { client ->
if (filterId == null) {
client.request(
ApiPath.PATH_FILTERS_V2,
params.toPostRequestBuilder()
)
} else {
client.request(
"${ApiPath.PATH_FILTERS_V2}/$filterId",
params.toRequestBody().toPut()
)
}
}
}
private fun addKeywordArea(keyword: TootFilterKeyword) {
views.llKeywords.addView(VhKeyword(fk = keyword).views.root)
}
private fun deleteKeywordArea(vh: VhKeyword) {
views.llKeywords.children.find { it.tag == vh }
?.let { views.llKeywords.removeView(it) }
vh.id?.let { deleteIds.add(it) }
}
private inner class VhKeyword(
val fk: TootFilterKeyword,
val views: LvKeywordFilterBinding = LvKeywordFilterBinding.inflate(layoutInflater),
) {
init {
views.root.tag = this
views.etKeyword.setText(fk.keyword.trim())
views.cbFilterWordMatch.isChecked = fk.whole_word
views.btnDelete.setOnClickListener {
deleteKeywordArea(this)
}
}
// onSaveInstanceや保存時に呼ばれる
fun encodeJson() =
fk.encodeNewParam(newKeyword = keyword, newWholeWord = wholeWord)
val keyword: String
get() = views.etKeyword.text.toString().trim()
val wholeWord: Boolean
get() = views.cbFilterWordMatch.isChecked
val id: String?
get() = fk.id?.toString()?.notEmpty()
}
}

View File

@ -144,7 +144,7 @@ fun getVisibilityCaption(
visibility: TootVisibility,
): CharSequence {
val icon_id = getVisibilityIconId(isMisskeyData, visibility)
val iconId = getVisibilityIconId(isMisskeyData, visibility)
val sv = getVisibilityString(context, isMisskeyData, visibility)
val color = context.attrColor(R.attr.colorVectorDrawable)
val sb = SpannableStringBuilder()
@ -156,7 +156,7 @@ fun getVisibilityCaption(
sb.setSpan(
EmojiImageSpan(
context,
icon_id,
iconId,
useColorShader = true,
color = color
),
@ -300,28 +300,28 @@ private fun getOrientationString(orientation: Int?) = when (orientation) {
}
fun fixHorizontalPadding(v: View, dpDelta: Float = 12f) {
val pad_t = v.paddingTop
val pad_b = v.paddingBottom
val padT = v.paddingTop
val padB = v.paddingBottom
val dm = v.resources.displayMetrics
val widthDp = dm.widthPixels / dm.density
if (widthDp >= 640f && v.resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) {
val pad_lr = (0.5f + dpDelta * dm.density).toInt()
val padLr = (0.5f + dpDelta * dm.density).toInt()
when (PrefI.ipJustifyWindowContentPortrait()) {
PrefI.JWCP_START -> {
v.setPaddingRelative(pad_lr, pad_t, pad_lr + dm.widthPixels / 2, pad_b)
v.setPaddingRelative(padLr, padT, padLr + dm.widthPixels / 2, padB)
return
}
PrefI.JWCP_END -> {
v.setPaddingRelative(pad_lr + dm.widthPixels / 2, pad_t, pad_lr, pad_b)
v.setPaddingRelative(padLr + dm.widthPixels / 2, padT, padLr, padB)
return
}
}
}
val pad_lr = getHorizontalPadding(v, dpDelta)
v.setPaddingRelative(pad_lr, pad_t, pad_lr, pad_b)
val padLr = getHorizontalPadding(v, dpDelta)
v.setPaddingRelative(padLr, padT, padLr, padB)
}
fun fixHorizontalPadding0(v: View) = fixHorizontalPadding(v, 0f)
@ -351,9 +351,9 @@ fun fixHorizontalMargin(v: View) {
}
}
val pad_lr = getHorizontalPadding(v, 0f)
lp.leftMargin = pad_lr
lp.rightMargin = pad_lr
val padLr = getHorizontalPadding(v, 0f)
lp.leftMargin = padLr
lp.rightMargin = padLr
}
}

View File

@ -25,7 +25,7 @@ fun ActMain.openFilterMenu(accessInfo: SavedAccount, item: TootFilter?) {
ad.addAction(getString(R.string.delete)) {
filterDelete(accessInfo, item)
}
ad.show(this, getString(R.string.filter_of, item.phrase))
ad.show(this, getString(R.string.filter_of, item.displayString))
}
fun ActMain.filterDelete(
@ -35,7 +35,7 @@ fun ActMain.filterDelete(
) {
launchAndShowError {
if (!bConfirmed) {
confirm(R.string.filter_delete_confirm, filter.phrase)
confirm(R.string.filter_delete_confirm, filter.displayString)
}
var resultFilterList: List<TootFilter>? = null

View File

@ -42,6 +42,7 @@ object ApiPath {
const val PATH_STATUSES = "/api/v1/statuses/%s" // 1:status_id
const val PATH_FILTERS = "/api/v1/filters"
const val PATH_FILTERS_V2 = "/api/v2/filters"
const val PATH_MISSKEY_PROFILE_FOLLOWING = "/api/users/following"
const val PATH_MISSKEY_PROFILE_FOLLOWERS = "/api/users/followers"

View File

@ -678,15 +678,13 @@ open class TootAccount(parser: TootParser, src: JsonObject) : HostAndDomain {
return null
}
private fun parseSource(src: JsonObject?): Source? {
src ?: return null
return try {
Source(src)
private fun parseSource(src: JsonObject?): Source? =
try {
src?.let { Source(it) }
} catch (ex: Throwable) {
log.e("parseSource failed.")
log.e(ex, "parseSource failed.")
null
}
}
private fun findApDomain(acctArg: String?, linkHelper: LinkHelper?): Host? {
// acctから調べる

View File

@ -3,6 +3,7 @@ package jp.juggler.subwaytooter.api.entity
import jp.juggler.util.data.JsonArray
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.notBlank
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory
// https://docs.joinmastodon.org/entities/Filter/
@ -32,9 +33,6 @@ class TootFilter(src: JsonObject) : TimelineItem() {
val id: EntityId = EntityId.mayDefault(src.string("id"))
// v2
val title: String? = src.string("title")
private val contextBits: Int = TootFilterContext.parseBits(src.jsonArray("context"))
// フィルタの適用先の名前の文字列IDのリスト
@ -49,15 +47,10 @@ class TootFilter(src: JsonObject) : TimelineItem() {
// v2: filter_action is "warn" or "hide".
// v1: irreversible boolean flag.
val filter_action: String = src.string("filter_action") ?: run {
// v1
when (src.boolean("irreversible")) {
true -> "hide"
else -> "warn"
}
}
val hide: Boolean = src.string("filter_action") == "hide" ||
src.boolean("irreversible") == true
val keywords: List<TootFilterKeyword>? =
var keywords: List<TootFilterKeyword> =
src.jsonArray("keywords")?.let { a ->
/* v2 */ a.objectList().map { TootFilterKeyword(it) }
} ?: src.string("phrase").notBlank()?.let {
@ -68,11 +61,22 @@ class TootFilter(src: JsonObject) : TimelineItem() {
whole_word = src.boolean("whole_word") ?: false
)
)
}
} ?: emptyList()
// フィルタにマッチしたステータスのIDのリスト
val statuses = src.jsonArray("statuses")?.objectList()?.map { TootFilterStatus(it) }
// v2
val title: String? = src.string("title") ?: keywords.firstOrNull()?.keyword
fun hasContext(fc: TootFilterContext) =
contextBits.and(fc.bit) != 0
val displayString: String
get() = keywords.joinToString(", ") { it.keyword }.let { keywords ->
when (val t = title?.notEmpty()) {
null, "" -> keywords
else -> "($t)$keywords"
}
}
}

View File

@ -10,7 +10,7 @@ enum class TootFilterContext(
// API中の識別子
val apiName: String,
// アプリに表示する文字列のID
val caption_id: Int
val caption_id: Int,
) {
Home(1, "home", R.string.filter_home),
Notifications(2, "notifications", R.string.filter_notification),
@ -24,7 +24,7 @@ enum class TootFilterContext(
private val log = LogCategory("TootFilterContext")
private val valuesCache = values()
private val apiNameMap = values().associateBy { it.name }
private val apiNameMap = valuesCache.associateBy { it.apiName }
fun parseBits(src: JsonArray?): Int =
src?.stringList()?.mapNotNull { apiNameMap[it]?.bit }?.sum() ?: 0

View File

@ -1,17 +1,37 @@
package jp.juggler.subwaytooter.api.entity
import jp.juggler.util.data.JsonArray
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.buildJsonObject
/**
* 単語フィルタのキーワード一つ
* 編集画面でも使うのでmutable
*/
class TootFilterKeyword(
var id: EntityId? = null,// v1 has no id
var keyword: String?,
var whole_word: Boolean,
// v1 has no id
var id: EntityId? = null,
var keyword: String,
var whole_word: Boolean = true,
) {
companion object {
private const val JSON_ID = "id"
private const val JSON_KEYWORD = "keyword"
private const val JSON_WHOLE_WORD = "whole_word"
}
// from Mastodon api/v2/filter
constructor(src: JsonObject) : this(
id = EntityId.mayNull(src.string("id")),
keyword = src.string("keyword"),
whole_word = src.boolean("whole_word") ?: true,
id = EntityId.mayNull(src.string(JSON_ID)),
keyword = src.string(JSON_KEYWORD) ?: "",
whole_word = src.boolean(JSON_WHOLE_WORD) ?: true,
)
fun encodeNewParam(
newKeyword: String,
newWholeWord: Boolean,
) = buildJsonObject {
put(JSON_ID, id.toString())
put(JSON_KEYWORD, newKeyword)
put(JSON_WHOLE_WORD, newWholeWord)
}
}

View File

@ -1,8 +1,20 @@
package jp.juggler.subwaytooter.api.entity
import jp.juggler.util.data.JsonArray
import jp.juggler.util.data.JsonObject
import jp.juggler.util.log.LogCategory
class TootFilterResult(src: JsonObject) {
companion object {
private val log = LogCategory("TootFilterResult")
fun parseList(src: JsonArray?): List<TootFilterResult>? =
try {
src?.objectList()?.map { TootFilterResult(it) }
} catch (ex: Throwable) {
log.e(ex, "parseList failed")
null
}
}
val filter: TootFilter? =
TootFilter.parse1(src.jsonObject("filter"))
@ -12,4 +24,7 @@ class TootFilterResult(src: JsonObject) {
val status_matches: EntityId? =
EntityId.mayNull(src.string("status_matches"))
val isHide
get() = filter?.hide == true
}

View File

@ -266,6 +266,7 @@ class TootInstance(parser: TootParser, src: JsonObject) {
val VERSION_3_3_0_rc1 = VersionString("3.3.0rc1")
val VERSION_3_4_0_rc1 = VersionString("3.4.0rc1")
val VERSION_3_5_0_rc1 = VersionString("3.5.0rc1")
val VERSION_4_0_0 = VersionString("4.0.0")
val MISSKEY_VERSION_11 = VersionString("11.0")
val MISSKEY_VERSION_12 = VersionString("12.0")

View File

@ -28,8 +28,8 @@ import kotlin.math.max
import kotlin.math.min
class FilterTrees(
val treeIrreversible: WordTrieTree = WordTrieTree(),
val treeReversible: WordTrieTree = WordTrieTree(),
val treeHide: WordTrieTree = WordTrieTree(),
val treeWarn: WordTrieTree = WordTrieTree(),
val treeAll: WordTrieTree = WordTrieTree(),
)
@ -186,6 +186,9 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() {
// Mastodon 3.5.0
var time_edited_at = 0L
// Mastodon 4.0.0
var filteredV4: List<TootFilterResult>? = null
///////////////////////////////////////////////////////////////////
// 以下はentityから取得したデータではなく、アプリ内部で使う
@ -639,6 +642,8 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() {
this.visibility = TootVisibility.parseMastodon(visibilityString)
?: TootVisibility.Unknown
this.sensitive = src.optBoolean("sensitive")
this.filteredV4 = TootFilterResult.parseList(src.jsonArray("filtered"))
}
ServiceType.TOOTSEARCH -> {
@ -904,55 +909,91 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() {
fun updateKeywordFilteredFlag(
accessInfo: SavedAccount,
trees: FilterTrees?,
checkIrreversible: Boolean = false,
matchedFiltersV4: List<TootFilterResult>? = null,
// フィルタ更新時などは隠すフィルタも含めてチェックする
checkAll: Boolean = false,
) {
trees ?: return
val desc = if (accessInfo.isMe(account) || accessInfo.isMe(reblog?.account)) {
null
} else {
val tree = if (checkAll) trees.treeAll else trees.treeWarn
val m1 = matchKeywordFilter(accessInfo, tree)
val m2 = reblog?.matchKeywordFilter(accessInfo, tree)
// status from me or boosted by me is not filtered.
if (accessInfo.isMe(account)) {
_filteredWord = null
return
if (m1.isNullOrEmpty() &&
m2.isNullOrEmpty() &&
matchedFiltersV4.isNullOrEmpty()
) {
null
} else {
val list = ArrayList<String>()
fun String.addToList() {
if (this.isNotEmpty() && !list.contains(this)) list.add(this)
}
fun List<String>.addToList() {
for (s in this) s.addToList()
}
matchedFiltersV4?.forEach { it.filter?.title?.addToList() }
m1?.forEach { m ->
m.tags?.mapNotNull { (it as? TootFilter)?.title }
?.addToList()
}
m2?.forEach { m ->
m.tags?.mapNotNull { (it as? TootFilter)?.title }
?.addToList()
}
if (list.isEmpty()) {
matchedFiltersV4?.forEach { fr ->
fr.filter?.keywords?.map { it.keyword }?.addToList()
}
m1?.forEach { m ->
m.word.notEmpty()?.let { list.add(it) }
}
m2?.forEach { m ->
m.word.notEmpty()?.let { list.add(it) }
}
}
list.joinToString(", ")
}
}
_filteredWord =
isKeywordFilteredSub(if (checkIrreversible) trees.treeAll else trees.treeReversible)
?.joinToString(", ")
reblog?.updateKeywordFilteredFlag(accessInfo, trees, checkIrreversible)
_filteredWord = desc
reblog?._filteredWord = desc
}
fun isKeywordFiltered(accessInfo: SavedAccount, tree: WordTrieTree?): Boolean {
tree ?: return false
fun matchKeywordFilterWithReblog(
accessInfo: SavedAccount,
tree: WordTrieTree?,
): List<WordTrieTree.Match>? {
matchKeywordFilter(accessInfo, tree)
?.notEmpty()?.let { return it }
// status from me or boosted by me is not filtered.
if (accessInfo.isMe(account)) return false
reblog?.matchKeywordFilter(accessInfo, tree)
?.notEmpty()?.let { return it }
if (isKeywordFilteredSub(tree) != null) return true
if (reblog?.isKeywordFilteredSub(tree) != null) return true
return false
return null
}
private fun isKeywordFilteredSub(tree: WordTrieTree): ArrayList<String>? {
private fun matchKeywordFilter(
accessInfo: SavedAccount,
tree: WordTrieTree?,
): ArrayList<WordTrieTree.Match>? {
// フィルタ単語がない、または
if (tree.isNullOrEmpty() || accessInfo.isMe(account)) return null
var list: ArrayList<String>? = null
var list: ArrayList<WordTrieTree.Match>? = null
fun check(t: CharSequence?) {
if (t?.isEmpty() != false) return
if (t.isNullOrEmpty()) return
val matches = tree.matchList(t) ?: return
var dst = list
if (dst == null) {
dst = ArrayList()
list = dst
}
for (m in matches)
dst.add(m.word)
(list ?: ArrayList<WordTrieTree.Match>().also { list = it })
.addAll(matches)
}
check(decoded_spoiler_text)
check(decoded_content)
media_attachments?.forEach { check(it.description) }
return list
}

View File

@ -70,7 +70,7 @@ fun Column.canStatusFilter() =
ColumnType.SEARCH_NOTESTOCK,
ColumnType.STATUS_HISTORY,
-> true
else -> getFilterContext() !=null
else -> getFilterContext() != null
}
// カラム設定に「すべての画像を隠す」ボタンを含めるなら真
@ -142,7 +142,7 @@ fun Column.onFilterDeleted(filter: TootFilter, filterList: List<TootFilter>) {
fireShowContent(reason = "onFilterDeleted")
}
} else {
if( getFilterContext() != null){
if (getFilterContext() != null) {
onFiltersChanged2(filterList)
}
}
@ -189,15 +189,39 @@ private fun Column.isFilteredByAttachment(status: TootStatus): Boolean {
fun Column.isFiltered(status: TootStatus): Boolean {
val filterTrees = keywordFilterTrees
if (filterTrees != null) {
if (status.isKeywordFiltered(accessInfo, filterTrees.treeIrreversible)) {
log.d("status filtered by treeIrreversible")
return true
}
val isMe = accessInfo.isMe(status.account) ||
accessInfo.isMe(status.reblog?.account)
// just update _filtered flag for reversible filter
status.updateKeywordFilteredFlag(accessInfo, filterTrees)
val filterTrees = keywordFilterTrees
if (filterTrees != null && !isMe) {
val ti = TootInstance.getCached(accessInfo)
if (ti?.versionGE(TootInstance.VERSION_4_0_0) == true) {
// v4 はサーバ側でフィルタしてる
// XXX: フィルタが後から更新されたら再チェックが必要か?
val filteredV4 = status.filteredV4 ?: status.reblog?.filteredV4
if (filteredV4.isNullOrEmpty()) {
// フィルタされていない
} else if (filteredV4.any { it.isHide }) {
// 隠すフィルタ
log.d("isFiltered: status muted by filteredV4 hide.")
return true
} else {
// 警告フィルタ
status.updateKeywordFilteredFlag(
accessInfo,
filterTrees,
matchedFiltersV4 = filteredV4
)
}
} else {
if (status.matchKeywordFilterWithReblog(accessInfo, filterTrees.treeHide) != null) {
log.d("status filtered by treeIrreversible")
return true
} else {
// 警告フィルタ
status.updateKeywordFilteredFlag(accessInfo, filterTrees)
}
}
}
if (isFilteredByAttachment(status)) return true
@ -339,14 +363,39 @@ fun Column.isFiltered(item: TootNotification): Boolean {
val status = item.status
val filterTrees = keywordFilterTrees
if (status != null && filterTrees != null) {
if (status.isKeywordFiltered(accessInfo, filterTrees.treeIrreversible)) {
log.d("isFiltered: status muted by treeIrreversible.")
return true
val ti = TootInstance.getCached(accessInfo)
if (ti?.versionGE(TootInstance.VERSION_4_0_0) == true) {
// v4 はサーバ側でフィルタしてる
// XXX: フィルタが後から更新されたら再チェックが必要か?
val filterResults = status.filteredV4 ?: status.reblog?.filteredV4
if (filterResults.isNullOrEmpty()) {
// フィルタされていない
} else if (filterResults.any { it.isHide }) {
// 隠すフィルタ
log.d("isFiltered: status muted by filteredV4 hide.")
return true
} else {
// 警告フィルタ
status.updateKeywordFilteredFlag(
accessInfo,
filterTrees,
matchedFiltersV4 = filterResults
)
}
} else {
// v4未満は端末側でのチェック
if (status.matchKeywordFilterWithReblog(accessInfo, filterTrees.treeHide) != null) {
// 隠すフィルタ
log.d("isFiltered: status muted by treeIrreversible.")
return true
} else {
// 警告フィルタ
// just update _filtered flag for reversible filter
status.updateKeywordFilteredFlag(accessInfo, filterTrees)
}
}
// just update _filtered flag for reversible filter
status.updateKeywordFilteredFlag(accessInfo, filterTrees)
}
if (checkLanguageFilter(status)) return true
if (status?.checkMuted() == true) {
@ -383,8 +432,11 @@ fun Column.isFiltered(item: TootNotification): Boolean {
// フィルタを読み直してリストを返す。またはnull
suspend fun Column.loadFilter2(client: TootApiClient): List<TootFilter>? {
if (accessInfo.isPseudo || accessInfo.isMisskey) return null
if (getFilterContext() ==null) return null
val result = client.request(ApiPath.PATH_FILTERS)
if (getFilterContext() == null) return null
var result = client.request(ApiPath.PATH_FILTERS_V2)
if (result?.response?.code == 404) {
result = client.request(ApiPath.PATH_FILTERS)
}
val jsonArray = result?.jsonArray ?: return null
return TootFilter.parseList(jsonArray)
@ -399,22 +451,30 @@ fun Column.encodeFilterTree(filterList: List<TootFilter>?): FilterTrees? {
if (filter.time_expires_at > 0L && now >= filter.time_expires_at) continue
if (!filter.hasContext(columnContext)) continue
val validator = when (filter.whole_word) {
true -> WordTrieTree.WORD_VALIDATOR
else -> WordTrieTree.EMPTY_VALIDATOR
for (kw in filter.keywords) {
val validator = when (kw.whole_word) {
true -> WordTrieTree.WORD_VALIDATOR
else -> WordTrieTree.EMPTY_VALIDATOR
}
when (filter.hide) {
true -> result.treeHide
else -> result.treeWarn
}.add(
s = kw.keyword,
tag = filter,
validator = validator
)
result.treeAll.add(
s = kw.keyword,
tag = filter,
validator = validator
)
}
if (filter.irreversible) {
result.treeIrreversible
} else {
result.treeReversible
}.add(filter.phrase, validator = validator)
result.treeAll.add(filter.phrase, validator = validator)
}
return result
}
// フィルタ更新時に全部チェックし直す
fun Column.checkFiltersForListData(trees: FilterTrees?) {
trees ?: return
val changeList = ArrayList<AdapterChange>()
@ -422,7 +482,7 @@ fun Column.checkFiltersForListData(trees: FilterTrees?) {
when (item) {
is TootStatus -> {
val oldFiltered = item.filtered
item.updateKeywordFilteredFlag(accessInfo, trees, checkIrreversible = true)
item.updateKeywordFilteredFlag(accessInfo, trees, checkAll = true)
if (oldFiltered != item.filtered) {
changeList.add(AdapterChange(AdapterChangeType.RangeChange, idx))
}
@ -432,7 +492,7 @@ fun Column.checkFiltersForListData(trees: FilterTrees?) {
val s = item.status
if (s != null) {
val oldFiltered = s.filtered
s.updateKeywordFilteredFlag(accessInfo, trees, checkIrreversible = true)
s.updateKeywordFilteredFlag(accessInfo, trees, checkAll = true)
if (oldFiltered != s.filtered) {
changeList.add(AdapterChange(AdapterChangeType.RangeChange, idx))
}
@ -451,11 +511,14 @@ fun reloadFilter(context: Context, accessInfo: SavedAccount) {
accessInfo,
progressStyle = ApiTask.PROGRESS_NONE
) { client ->
client.request(ApiPath.PATH_FILTERS)?.also { result ->
result.jsonArray?.let {
resultList = TootFilter.parseList(it)
}
var result = client.request(ApiPath.PATH_FILTERS_V2)
if (result?.response?.code == 404) {
result = client.request(ApiPath.PATH_FILTERS)
}
result?.jsonArray?.let {
resultList = TootFilter.parseList(it)
}
result
}
resultList?.let {

View File

@ -898,14 +898,14 @@ class ColumnTask_Loading(
return result
}
suspend fun getFilterList(
client: TootApiClient,
pathBase: String,
): TootApiResult? {
val result = client.request(pathBase)
suspend fun getFilterList(client: TootApiClient): TootApiResult? {
var result = client.request(ApiPath.PATH_FILTERS_V2)
if (result?.response?.code == 404) {
result = client.request(ApiPath.PATH_FILTERS)
}
if (result != null) {
val src = TootFilter.parseList(result.jsonArray)
this.listTmp = addAll(null, src?: emptyList())
this.listTmp = addAll(null, src ?: emptyList())
}
return result
}

View File

@ -1905,7 +1905,9 @@ enum class ColumnType(
bAllowMisskey = false,
headerType = HeaderType.Filter,
loading = { client -> getFilterList(client, ApiPath.PATH_FILTERS) },
loading = { client ->
getFilterList(client)
},
canStreamingMastodon = streamingTypeNo,
canStreamingMisskey = streamingTypeNo,

View File

@ -235,7 +235,7 @@ fun ColumnViewHolder.setScrollPosition(sp: ScrollPosition, deltaDp: Float = 0f)
val state = ColumnViewHolder.fieldState.get(listView) as RecyclerView.State
listLayoutManager.scrollVerticallyBy(dy, recycler, state)
} catch (ex: Throwable) {
log.e("can't access field in class ${RecyclerView::class.java.simpleName}")
log.e(ex, "can't access field in class ${RecyclerView::class.java.simpleName}")
}
}, 20L)
}

View File

@ -26,7 +26,6 @@ import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.pref.PrefI
import jp.juggler.subwaytooter.span.MyClickableSpan
import jp.juggler.subwaytooter.table.*
import jp.juggler.util.log.Benchmark
import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.subwaytooter.view.MyNetworkImageView
import jp.juggler.util.*
@ -394,30 +393,30 @@ fun ItemViewHolder.showDomainBlock(domainBlock: TootDomainBlock) {
fun ItemViewHolder.showFilter(filter: TootFilter) {
llFilter.visibility = View.VISIBLE
tvFilterPhrase.text = filter.phrase
tvFilterPhrase.text = filter.displayString
val sb = StringBuffer()
//
sb.append(activity.getString(R.string.filter_context))
.append(": ")
.append(filter.contextNames.joinToString("/") { activity.getString(it) })
//
val flags = ArrayList<String>()
if (filter.irreversible) flags.add(activity.getString(R.string.filter_irreversible))
if (filter.whole_word) flags.add(activity.getString(R.string.filter_word_match))
if (flags.isNotEmpty()) {
sb.append('\n')
.append(flags.joinToString(", "))
}
//
if (filter.time_expires_at != 0L) {
sb.append('\n')
.append(activity.getString(R.string.filter_expires_at))
.append(": ")
.append(TootStatus.formatTime(activity, filter.time_expires_at, false))
}
tvFilterDetail.text = StringBuffer().apply {
val contextNames = filter.contextNames.joinToString("/") { activity.getString(it) }
append(activity.getString(R.string.filter_context))
append(": ")
append(contextNames)
tvFilterDetail.text = sb.toString()
val action = when (filter.hide) {
true -> activity.getString(R.string.filter_action_hide)
else -> activity.getString(R.string.filter_action_warn)
}
append('\n')
append(activity.getString(R.string.filter_action))
append(": ")
append(action)
if (filter.time_expires_at > 0L) {
append('\n')
append(activity.getString(R.string.filter_expires_at))
append(": ")
append(TootStatus.formatTime(activity, filter.time_expires_at, false))
}
}.toString()
}
fun ItemViewHolder.showSearchTag(tag: TootTag) {

View File

@ -103,7 +103,7 @@ class ProgressResponseBody private constructor(
return buffer.readByteArray()
} catch (ex: Throwable) {
log.e("readByteArray() failed. ")
log.e(ex, "readByteArray() failed.")
return originalSource.readByteArray()
}
}

View File

@ -13,8 +13,8 @@
android:clipToPadding="false"
android:fillViewport="true"
android:paddingTop="12dp"
android:paddingBottom="128dp"
android:paddingTop="12dp"
android:scrollbarStyle="outsideOverlay"
tools:ignore="TooManyViews">
@ -37,17 +37,63 @@
<TextView
style="@style/setting_row_label"
android:labelFor="@+id/etPhrase"
android:text="@string/filter_phrase" />
android:labelFor="@+id/etTitle"
android:text="@string/filter_title" />
<EditText
android:id="@+id/etPhrase"
android:id="@+id/etTitle"
style="@style/setting_row_form"
android:importantForAutofill="no"
android:inputType="text" />
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/filter_phrase" />
<LinearLayout
android:id="@+id/llKeywords"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
</LinearLayout>
<Button
android:id="@+id/btnAddKeyword"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawableStart="@drawable/ic_add"
android:text="@string/add_keyword_or_phrase"
android:textAllCaps="false" />
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/filter_action" />
<RadioGroup
android:id="@+id/rgAction"
style="@style/setting_row_form"
android:orientation="vertical">
<RadioButton
android:id="@+id/rbWarn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/filter_action_warn" />
<RadioButton
android:id="@+id/rbHide"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/filter_action_hide" />
</RadioGroup>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/filter_context" />
@ -84,24 +130,6 @@
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/filter_options" />
<CheckBox
android:id="@+id/cbFilterIrreversible"
style="@style/setting_row_form"
android:text="@string/filter_irreversible_long" />
<CheckBox
android:id="@+id/cbFilterWordMatch"
style="@style/setting_row_form"
android:layout_marginTop="8dp"
android:text="@string/filter_word_match_long" />
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/filter_expires_at" />

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="2dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:labelFor="@+id/etKeyword"
android:text="@string/keyword_or_phrase" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorPostFormBackground"
android:padding="1dp">
<EditText
android:id="@+id/etKeyword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:importantForAutofill="no"
android:inputType="text" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<CheckBox
android:id="@+id/cbFilterWordMatch"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical"
android:text="@string/filter_word_match_long" />
<ImageButton
android:id="@+id/btnDelete"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="16dp"
android:contentDescription="@string/delete"
android:src="@drawable/ic_delete"
app:tint="?attr/colorVectorDrawable" />
</LinearLayout>
</LinearLayout>

View File

@ -1165,4 +1165,14 @@
<string name="permission_denied_media_access">端末上のデータにアクセスする権限がありません。</string>
<string name="popup_background_color">ポップアップ背景色</string>
<string name="filter_title">フィルタ名</string>
<string name="filter_action">フィルタ処理</string>
<string name="filter_action_warn">フィルタされたことを表示する</string>
<string name="filter_action_hide">完全に隠す。(Mastodon 3.xまでは非可逆を表す)</string>
<string name="keyword_or_phrase">キーワードやフレーズ</string>
<string name="add_keyword_or_phrase">キーワードやフレーズを追加</string>
<string name="filter_keyword_empty">キーワードがカラです</string>
<string name="filter_title_empty">フィルタ名がカラです</string>
</resources>

View File

@ -1173,4 +1173,12 @@
<string name="permission_rational_media_access">Permission required to access the device\'s media data.</string>
<string name="permission_denied_media_access">Missing app permission to access the device\'s media data.</string>
<string name="popup_background_color">Popup background color</string>
<string name="filter_title">Filter title</string>
<string name="filter_action">Filter action</string>
<string name="filter_action_warn">Hide, but warn about filter.</string>
<string name="filter_action_hide">Hide. (for old server, it works as Irreversible)</string>
<string name="keyword_or_phrase">Keyword or phrase</string>
<string name="add_keyword_or_phrase">Add Keyword or phrase</string>
<string name="filter_keyword_empty">filter keyword(s) is empty.</string>
<string name="filter_title_empty">filter title is empty.</string>
</resources>

View File

@ -1,16 +1,11 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id "io.gitlab.arturbosch.detekt"
// import io.gitlab.arturbosch.detekt.Detekt
// import io.gitlab.arturbosch.detekt.DetektPlugin
//
// import java.text.SimpleDateFormat
//
// apply plugin: 'com.android.application'
// apply plugin: 'kotlin-android'
// apply plugin: 'kotlin-kapt'
// import java.text.SimpleDateFormat
// apply plugin: 'com.android.application'
// apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
// apply plugin: 'com.google.gms.google-services'
}
@ -53,9 +48,6 @@ dependencies {
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:$detekt_version")
detektPlugins("io.gitlab.arturbosch.detekt:detekt-cli:$detekt_version")
// desugar_jdk_libs 2.0.0 AGP 7.4.0-alpha10
//noinspection GradleDependency
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$desugar_lib_bersion"
@ -111,3 +103,5 @@ dependencies {
implementation "com.squareup.okhttp3:okhttp-urlconnection:$okhttpVersion"
implementation "ru.gildor.coroutines:kotlin-coroutines-okhttp:1.0"
}

View File

@ -6,7 +6,7 @@ import jp.juggler.util.log.LogCategory
class JugglerBaseInitializer : Initializer<Boolean> {
companion object {
private val log = LogCategory("JugglerBaseInitializer")
private val log = LogCategory("JugglerBaseInitializer")
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()

View File

@ -167,7 +167,7 @@ object CharacterGroup {
internal var end: Int = 0
var offset: Int = 0
internal fun reset(text: CharSequence, start: Int, end: Int): Tokenizer {
fun reset(text: CharSequence, start: Int, end: Int): Tokenizer {
this.text = text
this.offset = start
this.end = end

View File

@ -1,5 +1,9 @@
package jp.juggler.util.data
import androidx.collection.SparseArrayCompat
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
class WordTrieTree {
companion object {
@ -42,11 +46,13 @@ class WordTrieTree {
private class Node {
// 続くノード
val childNodes = androidx.collection.SparseArrayCompat<Node>()
val childNodes = SparseArrayCompat<Node>()
// このノードが終端なら、マッチした単語の元の表記がある
var matchWord: String? = null
var matchTags: ArrayList<Any>? = null
// Trieツリー的には終端単語と続くードの両方が存在する場合がありうる。
// たとえば ABC と ABCDEF を登録してから ABCDEFG を探索したら、単語 ABC と単語 ABCDEF にマッチする。
@ -63,6 +69,7 @@ class WordTrieTree {
// 単語の追加
fun add(
s: String,
tag:Any?=null,
validator: (src: CharSequence, start: Int, end: Int) -> Boolean = EMPTY_VALIDATOR,
) {
val t = CharacterGroup.Tokenizer().reset(s, 0, s.length)
@ -84,6 +91,13 @@ class WordTrieTree {
node.validator = validator
}
// タグを覚える
if(tag!=null){
val tags = node.matchTags
?: ArrayList<Any>().also{ node.matchTags = it}
tags.add(tag)
}
return
}
@ -98,7 +112,12 @@ class WordTrieTree {
}
// マッチ結果
class Match internal constructor(val start: Int, val end: Int, val word: String)
class Match internal constructor(
val start: Int,
val end: Int,
val word: String,
val tags: ArrayList<Any>?,
)
// Tokenizer が列挙する文字を使って Trie Tree を探索する
private fun match(
@ -117,7 +136,7 @@ class WordTrieTree {
if (matchWord != null && node.validator(t.text, start, t.offset)) {
// マッチしたことを覚えておく
dst = Match(start, t.offset, matchWord)
dst = Match(start, t.offset, matchWord ,node.matchTags)
// ミュート用途の場合、ひとつでも単語にマッチすればより長い探索は必要ない
if (allowShortMatch) break
@ -184,3 +203,11 @@ class WordTrieTree {
return dst
}
}
@OptIn(ExperimentalContracts::class)
fun WordTrieTree?.isNullOrEmpty() :Boolean {
contract {
returns(false) implies (this@isNullOrEmpty != null)
}
return this == null || this.isEmpty
}

View File

@ -1,3 +1,5 @@
import io.gitlab.arturbosch.detekt.Detekt
buildscript {
ext.jvm_target = "1.8"
@ -23,7 +25,7 @@ buildscript {
ext.junit_version = '4.13.2'
ext.detekt_version = '1.21.0-RC1'
ext.detekt_version = '1.22.0'
ext.compose_version = '1.0.5'

View File

@ -75,6 +75,11 @@ comments:
complexity:
active: true
CyclomaticComplexMethod:
active: false
threshold: 20
ComplexCondition:
active: true
threshold: 8
@ -247,6 +252,10 @@ formatting:
active: true
android: false
autoCorrect: true
MultiLineIfElse:
active: false
Wrapping:
active: false
AnnotationOnSeparateLine:
@ -270,7 +279,7 @@ formatting:
active: false
autoCorrect: true
Filename:
active: true
active: false
FinalNewline:
active: false
autoCorrect: true
@ -292,7 +301,7 @@ formatting:
active: true
autoCorrect: true
MultiLineIfElse:
active: true
active: false
autoCorrect: true
NoBlankLineBeforeRbrace:
active: true