From a51b45d0cde81a9daf6f27166fbea255ef5d4fd4 Mon Sep 17 00:00:00 2001 From: tateisu Date: Sat, 14 Jan 2023 19:19:01 +0900 Subject: [PATCH] =?UTF-8?q?Mastodon=204.0=E3=81=AE=E3=83=95=E3=82=A3?= =?UTF-8?q?=E3=83=AB=E3=82=BFAPI=20v2=20=E3=82=92=E4=BD=BF=E3=81=A3?= =?UTF-8?q?=E3=81=A6=E3=81=BF=E3=82=8B=E3=80=82detektAll=E3=82=BF=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E3=81=A7=E8=A4=87=E6=95=B0=E3=83=A2=E3=82=B8=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=82=92=E3=81=BE=E3=81=A8=E3=82=81=E3=81=A6?= =?UTF-8?q?=E6=A4=9C=E6=9F=BB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 64 ++-- .../subwaytooter/TestMisskeyMentionAndroid.kt | 5 +- .../juggler/subwaytooter/WordTrieTreeTest.kt | 5 +- .../subwaytooter/api/TestDuplicateMap.kt | 21 +- .../subwaytooter/api/TestTootApiClient.kt | 78 ++-- .../api/entity/TestEntityUtils.kt | 20 +- .../subwaytooter/database/TestDatabase.kt | 1 - .../subwaytooter/util/TestBucketList.kt | 1 - .../juggler/subwaytooter/ActKeywordFilter.kt | 362 ++++++++++++------ .../java/jp/juggler/subwaytooter/Styler.kt | 24 +- .../subwaytooter/action/Action_Filter.kt | 4 +- .../jp/juggler/subwaytooter/api/ApiPath.kt | 1 + .../subwaytooter/api/entity/TootAccount.kt | 10 +- .../subwaytooter/api/entity/TootFilter.kt | 28 +- .../api/entity/TootFilterContext.kt | 4 +- .../api/entity/TootFilterKeyword.kt | 34 +- .../api/entity/TootFilterResult.kt | 15 + .../subwaytooter/api/entity/TootInstance.kt | 1 + .../subwaytooter/api/entity/TootStatus.kt | 109 ++++-- .../subwaytooter/column/ColumnFilters.kt | 133 +++++-- .../subwaytooter/column/ColumnTask_Loading.kt | 12 +- .../juggler/subwaytooter/column/ColumnType.kt | 4 +- .../ColumnViewHolderLoading.kt | 2 +- .../subwaytooter/dialog/ProgressDialogEx.kt | 0 .../itemviewholder/ItemViewHolderShow.kt | 45 ++- .../subwaytooter/util/ProgressResponseBody.kt | 2 +- .../main/res/layout/act_keyword_filter.xml | 72 ++-- app/src/main/res/layout/lv_keyword_filter.xml | 55 +++ app/src/main/res/values-ja/strings.xml | 10 + app/src/main/res/values/strings.xml | 8 + base/build.gradle | 14 +- .../jp/juggler/base/JugglerBaseInitializer.kt | 2 +- .../jp/juggler/util/data/CharacterGroup.kt | 2 +- .../java/jp/juggler/util/data/WordTrieTree.kt | 33 +- build.gradle | 4 +- config/detekt/config.yml | 13 +- 36 files changed, 805 insertions(+), 393 deletions(-) delete mode 100644 app/src/main/java/jp/juggler/subwaytooter/dialog/ProgressDialogEx.kt create mode 100644 app/src/main/res/layout/lv_keyword_filter.xml diff --git a/app/build.gradle b/app/build.gradle index 9644c0a7..9edc243f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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") } } diff --git a/app/src/androidTest/java/jp/juggler/subwaytooter/TestMisskeyMentionAndroid.kt b/app/src/androidTest/java/jp/juggler/subwaytooter/TestMisskeyMentionAndroid.kt index a650ca54..2793de90 100644 --- a/app/src/androidTest/java/jp/juggler/subwaytooter/TestMisskeyMentionAndroid.kt +++ b/app/src/androidTest/java/jp/juggler/subwaytooter/TestMisskeyMentionAndroid.kt @@ -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") ) } - } \ No newline at end of file diff --git a/app/src/androidTest/java/jp/juggler/subwaytooter/WordTrieTreeTest.kt b/app/src/androidTest/java/jp/juggler/subwaytooter/WordTrieTreeTest.kt index fcedeb01..a7dacbad 100644 --- a/app/src/androidTest/java/jp/juggler/subwaytooter/WordTrieTreeTest.kt +++ b/app/src/androidTest/java/jp/juggler/subwaytooter/WordTrieTreeTest.kt @@ -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()) // 末尾の空白はマッチ範囲には含まれない } } - } diff --git a/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestDuplicateMap.kt b/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestDuplicateMap.kt index 761eb3e2..fdf0997f 100644 --- a/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestDuplicateMap.kt +++ b/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestDuplicateMap.kt @@ -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 { val generatedItems = ArrayList() fun checkNotification( map: DuplicateMap, parser: TootParser, - id: String + id: String, ) { val itemJson = JsonObject() @@ -144,7 +141,7 @@ class TestDuplicateMap { val generatedItems = ArrayList() 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 { val generatedItems = ArrayList() fun checkAccount( map: DuplicateMap, parser: TootParser, - id: String + id: String, ) { val itemJson = JsonObject() diff --git a/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestTootApiClient.kt b/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestTootApiClient.kt index bea93417..d69a3a69 100644 --- a/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestTootApiClient.kt +++ b/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestTootApiClient.kt @@ -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 { } } } - diff --git a/app/src/androidTest/java/jp/juggler/subwaytooter/api/entity/TestEntityUtils.kt b/app/src/androidTest/java/jp/juggler/subwaytooter/api/entity/TestEntityUtils.kt index 50a6424b..3c20a59b 100644 --- a/app/src/androidTest/java/jp/juggler/subwaytooter/api/entity/TestEntityUtils.kt +++ b/app/src/androidTest/java/jp/juggler/subwaytooter/api/entity/TestEntityUtils.kt @@ -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) diff --git a/app/src/androidTest/java/jp/juggler/subwaytooter/database/TestDatabase.kt b/app/src/androidTest/java/jp/juggler/subwaytooter/database/TestDatabase.kt index 118cff9b..422bf5e6 100644 --- a/app/src/androidTest/java/jp/juggler/subwaytooter/database/TestDatabase.kt +++ b/app/src/androidTest/java/jp/juggler/subwaytooter/database/TestDatabase.kt @@ -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 diff --git a/app/src/androidTest/java/jp/juggler/subwaytooter/util/TestBucketList.kt b/app/src/androidTest/java/jp/juggler/subwaytooter/util/TestBucketList.kt index 0576d13b..fdb051d9 100644 --- a/app/src/androidTest/java/jp/juggler/subwaytooter/util/TestBucketList.kt +++ b/app/src/androidTest/java/jp/juggler/subwaytooter/util/TestBucketList.kt @@ -1,6 +1,5 @@ package jp.juggler.subwaytooter.util - import androidx.test.runner.AndroidJUnit4 import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActKeywordFilter.kt b/app/src/main/java/jp/juggler/subwaytooter/ActKeywordFilter.kt index a82a2bbf..f7a15130 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActKeywordFilter.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActKeywordFilter.kt @@ -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() + /////////////////////////////////////////////////// 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(deleteIds)) + + views.llKeywords.children + .mapNotNull { (it.tag as? VhKeyword)?.encodeJson()?.toString() } + .toList() + .let { outState.putStringArrayList(STATE_KEYWORDS, ArrayList(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): 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): 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() + } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/Styler.kt b/app/src/main/java/jp/juggler/subwaytooter/Styler.kt index e1fe2651..68d9d7a3 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/Styler.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/Styler.kt @@ -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 } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Filter.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Filter.kt index 9883b960..310c0b9a 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Filter.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Filter.kt @@ -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? = null diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/ApiPath.kt b/app/src/main/java/jp/juggler/subwaytooter/api/ApiPath.kt index 5b2d777d..c70acc93 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/ApiPath.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/ApiPath.kt @@ -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" diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt index 215312fe..0d5b152c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt @@ -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から調べる diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilter.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilter.kt index b1454820..667267a1 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilter.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilter.kt @@ -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? = + var keywords: List = 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" + } + } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilterContext.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilterContext.kt index e096c385..a0fbb7a9 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilterContext.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilterContext.kt @@ -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 diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilterKeyword.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilterKeyword.kt index 09383beb..e494aab3 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilterKeyword.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilterKeyword.kt @@ -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) + } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilterResult.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilterResult.kt index 2628c1a2..3a421dd7 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilterResult.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootFilterResult.kt @@ -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? = + 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 } diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootInstance.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootInstance.kt index 4bb4ab34..2e74a0a3 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootInstance.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootInstance.kt @@ -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") diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt index a474270d..4b88e8fb 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt @@ -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? = 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? = 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() + fun String.addToList() { + if (this.isNotEmpty() && !list.contains(this)) list.add(this) + } + + fun List.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? { + 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? { + private fun matchKeywordFilter( + accessInfo: SavedAccount, + tree: WordTrieTree?, + ): ArrayList? { + // フィルタ単語がない、または + if (tree.isNullOrEmpty() || accessInfo.isMe(account)) return null - var list: ArrayList? = null + var list: ArrayList? = 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().also { list = it }) + .addAll(matches) } - check(decoded_spoiler_text) check(decoded_content) - + media_attachments?.forEach { check(it.description) } return list } diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnFilters.kt b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnFilters.kt index 98eea15e..28989982 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnFilters.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnFilters.kt @@ -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) { 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? { 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?): 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() @@ -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 { diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Loading.kt b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Loading.kt index 6100aca6..1b3fa7bb 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Loading.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Loading.kt @@ -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 } diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnType.kt b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnType.kt index d28f4e0b..ec617c2e 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnType.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnType.kt @@ -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, diff --git a/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderLoading.kt b/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderLoading.kt index 4c0978f7..dda4e82a 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderLoading.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderLoading.kt @@ -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) } diff --git a/app/src/main/java/jp/juggler/subwaytooter/dialog/ProgressDialogEx.kt b/app/src/main/java/jp/juggler/subwaytooter/dialog/ProgressDialogEx.kt deleted file mode 100644 index e69de29b..00000000 diff --git a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShow.kt b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShow.kt index 56eadb8f..dcfc84e3 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShow.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShow.kt @@ -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() - 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) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/ProgressResponseBody.kt b/app/src/main/java/jp/juggler/subwaytooter/util/ProgressResponseBody.kt index 0e9c1630..91201a4f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/ProgressResponseBody.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/ProgressResponseBody.kt @@ -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() } } diff --git a/app/src/main/res/layout/act_keyword_filter.xml b/app/src/main/res/layout/act_keyword_filter.xml index 66646988..70b047e3 100644 --- a/app/src/main/res/layout/act_keyword_filter.xml +++ b/app/src/main/res/layout/act_keyword_filter.xml @@ -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 @@ + android:labelFor="@+id/etTitle" + android:text="@string/filter_title" /> + + + + + + +