Mastodon 4.0のフィルタAPI v2 を使ってみる。detektAllタスクで複数モジュールをまとめて検査する
This commit is contained in:
parent
301905c016
commit
a51b45d0cd
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()) // 末尾の空白はマッチ範囲には含まれない
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package jp.juggler.subwaytooter.util
|
||||
|
||||
|
||||
import androidx.test.runner.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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から調べる
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue