Compare commits

...

5 Commits

41 changed files with 657 additions and 391 deletions

81
.gitignore vendored
View File

@ -1,3 +1,84 @@
#####################################
# https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
#####################################
# https://github.com/github/gitignore/blob/master/Android.gitignore
# Built application files

10
.idea/migrations.xml Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

View File

@ -164,7 +164,7 @@ dependencies {
implementation(project(":anko"))
implementation(fileTree(mapOf("dir" to "src/main/libs", "include" to arrayOf("*.aar"))))
"fcmImplementation"("com.google.firebase:firebase-messaging:23.3.1")
"fcmImplementation"("com.google.firebase:firebase-messaging:23.4.0")
"fcmImplementation"("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:${Vers.kotlinxCoroutinesVersion}")
// implementation "org.conscrypt:conscrypt-android:$conscryptVersion"
@ -272,25 +272,26 @@ tasks.register<Detekt>("detektAll") {
)
)
// val kotlinFiles = "**/*.kt"
// include(kotlinFiles)
val resourceFiles = "**/resources/**"
val buildFiles = "**/build/**"
exclude(resourceFiles, buildFiles)
reports {
val buildDir = layout.buildDirectory
xml.required.set(false)
xml.outputLocation.set(file("$buildDir/reports/detekt/st-${name}.xml"))
html.required.set(true)
html.outputLocation.set(file("$buildDir/reports/detekt/st-${name}.html"))
fun reportLocationByExt(ext: String) =
layout.buildDirectory
.file("reports/detekt/st-${name}.$ext")
.get()
.asFile
txt.required.set(true)
txt.outputLocation.set(file("$buildDir/reports/detekt/st-${name}.txt"))
txt.outputLocation.set(reportLocationByExt("txt"))
sarif.required.set(true)
sarif.outputLocation.set(file("$buildDir/reports/detekt/st-${name}.sarif"))
html.required.set(true)
html.outputLocation.set(reportLocationByExt("html"))
xml.required.set(false)
xml.outputLocation.set(reportLocationByExt("xml"))
sarif.required.set(false)
sarif.outputLocation.set(reportLocationByExt("sarif"))
}
}

View File

@ -19,7 +19,13 @@ import android.view.View.FOCUS_FORWARD
import android.view.ViewGroup
import android.view.Window
import android.view.inputmethod.EditorInfo
import android.widget.*
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.BaseAdapter
import android.widget.CompoundButton
import android.widget.FrameLayout
import android.widget.Spinner
import android.widget.TextView
import android.widget.TextView.OnEditorActionListener
import androidx.annotation.ColorInt
import androidx.annotation.WorkerThread
@ -53,22 +59,35 @@ import jp.juggler.subwaytooter.util.CustomShare
import jp.juggler.subwaytooter.util.CustomShareTarget
import jp.juggler.subwaytooter.util.cn
import jp.juggler.subwaytooter.view.MyTextView
import jp.juggler.util.*
import jp.juggler.util.backPressed
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchProgress
import jp.juggler.util.data.*
import jp.juggler.util.data.cast
import jp.juggler.util.data.defaultLocale
import jp.juggler.util.data.handleGetContentResult
import jp.juggler.util.data.intentOpenDocument
import jp.juggler.util.data.notEmpty
import jp.juggler.util.data.notZero
import jp.juggler.util.getPackageInfoCompat
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.dialogOrToast
import jp.juggler.util.log.showToast
import jp.juggler.util.log.withCaption
import jp.juggler.util.ui.*
import jp.juggler.util.queryIntentActivitiesCompat
import jp.juggler.util.ui.ActivityResultHandler
import jp.juggler.util.ui.attrColor
import jp.juggler.util.ui.hideKeyboard
import jp.juggler.util.ui.isEnabledAlpha
import jp.juggler.util.ui.isNotOk
import jp.juggler.util.ui.launch
import jp.juggler.util.ui.vg
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
import java.io.OutputStreamWriter
import java.text.NumberFormat
import java.util.*
import java.util.TimeZone
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.zip.ZipEntry
@ -175,7 +194,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli
if (savedInstanceState != null) {
try {
savedInstanceState.getString(STATE_CHOOSE_INTENT_TARGET)?.let { target ->
customShareTarget = CustomShareTarget.values().firstOrNull { it.name == target }
customShareTarget = CustomShareTarget.entries.find { it.name == target }
}
} catch (ex: Throwable) {
log.e(ex, "can't restore customShareTarget.")

View File

@ -79,12 +79,14 @@ class ActCallback : AppCompatActivity() {
sharedIntent.set(intent)
}
}
forbidUriFromApp(intent) -> {
// last_uriをクリアする
lastUri.set(null)
// ダイアログを閉じるまで画面遷移しない
return
}
else -> {
val uri = intent.data
if (uri != null) {
@ -123,7 +125,7 @@ class ActCallback : AppCompatActivity() {
val type = src.type
if (type.isMediaMimeType()) {
when (action){
when (action) {
Intent.ACTION_VIEW -> {
src.data?.let { uriOriginal ->
try {
@ -137,6 +139,7 @@ class ActCallback : AppCompatActivity() {
}
}
}
Intent.ACTION_SEND -> {
var uri = src.getStreamUriExtra()
?: return src // text/plainの場合
@ -152,7 +155,8 @@ class ActCallback : AppCompatActivity() {
log.e(ex, "remake failed. src=$src")
}
}
Intent.ACTION_SEND_MULTIPLE -> {
Intent.ACTION_SEND_MULTIPLE -> {
val listUri = src.getStreamUriListExtra()
?: return null
val listDst = ArrayList<Uri>()

View File

@ -247,7 +247,7 @@ class ActKeywordFilter : AppCompatActivity() {
if (result?.response?.code == 404) {
// try v1
result = client.request("${ApiPath.PATH_FILTERS}/$filterId")
result = client.request("${ApiPath.PATH_FILTERS_V1}/$filterId")
result?.jsonObject?.let {
try {
resultFilter = TootFilter(it)
@ -391,12 +391,12 @@ class ActKeywordFilter : AppCompatActivity() {
return runApiTask(account) { client ->
if (filterId == null) {
client.request(
ApiPath.PATH_FILTERS,
ApiPath.PATH_FILTERS_V1,
params.toPostRequestBuilder()
)
} else {
client.request(
"${ApiPath.PATH_FILTERS}/$filterId",
"${ApiPath.PATH_FILTERS_V1}/$filterId",
params.toRequestBody().toPut()
)
}

View File

@ -7,11 +7,17 @@ import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.actmain.addColumn
import jp.juggler.subwaytooter.actmain.reloadAccountSetting
import jp.juggler.subwaytooter.actmain.showColumnMatchAccount
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.ApiTask
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.api.entity.TootVisibility
import jp.juggler.subwaytooter.api.errorApiResult
import jp.juggler.subwaytooter.api.runApiTask2
import jp.juggler.subwaytooter.api.syncStatus
import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.column.findStatus
import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm
@ -27,6 +33,7 @@ import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.JsonObject
import jp.juggler.util.log.showToast
import jp.juggler.util.network.toPostRequestBuilder
import kotlinx.coroutines.CancellationException
import kotlin.math.max
private class BoostImpl(
@ -47,8 +54,6 @@ private class BoostImpl(
private val isPrivateToot = accessInfo.isMastodon &&
statusArg.visibility == TootVisibility.PrivateFollowers
private var bConfirmed = false
private fun preCheck(): Boolean {
// アカウントからステータスにブースト操作を行っているなら、何もしない
@ -73,15 +78,15 @@ private class BoostImpl(
} else {
val (result, status) = client.syncStatus(accessInfo, statusArg)
when {
result == null -> throw CancellationException()
status == null -> errorApiResult(result)
status.reblogged -> errorApiResult(getString(R.string.already_boosted))
status.reblogged -> errorApiResult(R.string.already_boosted)
else -> status
}
}
// ブースト結果をUIに反映させる
private fun after(result: TootApiResult?, newStatus: TootStatus?, unrenoteId: EntityId?) {
result ?: return // cancelled.
private fun after(newStatus: TootStatus?, unrenoteId: EntityId?) {
when {
// Misskeyでunrenoteに成功した
unrenoteId != null -> {
@ -143,36 +148,20 @@ private class BoostImpl(
}
callback()
}
else -> activity.showToast(true, result.error)
}
}
suspend fun boostApi(client: TootApiClient, targetStatus: TootStatus): TootApiResult? =
if (accessInfo.isMisskey) {
if (!bSet) {
val myRenoteId = targetStatus.myRenoteId ?: errorApiResult("missing renote id.")
client.request(
"/api/notes/delete",
accessInfo.putMisskeyApiToken().apply {
put("noteId", myRenoteId.toString())
put("renoteId", targetStatus.id.toString())
}.toPostRequestBuilder()
)?.also {
if (it.response?.code == 204) {
resultUnrenoteId = myRenoteId
}
}
} else {
client.request(
suspend fun boostApi(client: TootApiClient, targetStatus: TootStatus): TootApiResult =
when {
accessInfo.isMisskey -> when {
// misskey, create renote
bSet -> client.requestOrThrow(
"/api/notes/create",
accessInfo.putMisskeyApiToken().apply {
put("renoteId", targetStatus.id.toString())
}.toPostRequestBuilder()
)?.also { result ->
val jsonObject = result.jsonObject
if (jsonObject != null) {
).apply {
jsonObject?.let { jsonObject ->
val outerStatus =
parser.status(jsonObject.jsonObject("createdNote") ?: jsonObject)
val innerStatus = outerStatus?.reblog ?: outerStatus
@ -184,73 +173,84 @@ private class BoostImpl(
resultStatus = innerStatus
}
}
}
} else {
val b = JsonObject().apply {
if (visibility != null) put("visibility", visibility.strMastodon)
}.toPostRequestBuilder()
// misskey, delete renote
else -> {
val myRenoteId = targetStatus.myRenoteId
?: error("missing renote id.")
client.request(
client.requestOrThrow(
"/api/notes/delete",
accessInfo.putMisskeyApiToken().apply {
put("noteId", myRenoteId.toString())
put("renoteId", targetStatus.id.toString())
}.toPostRequestBuilder()
).apply {
if (response?.code == 204) {
resultUnrenoteId = myRenoteId
}
}
}
}
// mastodon, reblog or unreblog
else -> client.requestOrThrow(
"/api/v1/statuses/${targetStatus.id}/${if (bSet) "reblog" else "unreblog"}",
b
)?.also { result ->
requestBuilder = JsonObject().apply {
if (visibility != null) put("visibility", visibility.strMastodon)
}.toPostRequestBuilder()
).apply {
// reblogはreblogを表すStatusを返す
// unreblogはreblogしたStatusを返す
val s = parser.status(result.jsonObject)
val s = parser.status(jsonObject)
resultStatus = s?.reblog ?: s
}
}
fun run() {
activity.launchAndShowError {
if (!preCheck()) return@launchAndShowError
fun run() = activity.launchAndShowError {
if (!preCheck()) return@launchAndShowError
if (!bConfirmed) {
activity.confirm(
activity.getString(
when {
!bSet -> R.string.confirm_unboost_from
isPrivateToot -> R.string.confirm_boost_private_from
visibility == TootVisibility.PrivateFollowers -> R.string.confirm_private_boost_from
else -> R.string.confirm_boost_from
},
daoAcctColor.getNickname(accessInfo)
),
when (bSet) {
true -> accessInfo.confirmBoost
else -> accessInfo.confirmUnboost
}
) { newConfirmEnabled ->
when (bSet) {
true -> accessInfo.confirmBoost = newConfirmEnabled
else -> accessInfo.confirmUnboost = newConfirmEnabled
}
daoSavedAccount.save(accessInfo)
activity.reloadAccountSetting(accessInfo)
}
val confirmMessage = activity.getString(
when {
!bSet -> R.string.confirm_unboost_from
isPrivateToot -> R.string.confirm_boost_private_from
visibility == TootVisibility.PrivateFollowers ->
R.string.confirm_private_boost_from
else -> R.string.confirm_boost_from
},
daoAcctColor.getNickname(accessInfo)
)
activity.confirm(
message = confirmMessage,
isConfirmEnabled = when (bSet) {
true -> accessInfo.confirmBoost
else -> accessInfo.confirmUnboost
}
) { newConfirmEnabled ->
when (bSet) {
true -> accessInfo.confirmBoost = newConfirmEnabled
else -> accessInfo.confirmUnboost = newConfirmEnabled
}
daoSavedAccount.save(accessInfo)
activity.reloadAccountSetting(accessInfo)
}
// ブースト表示を更新中にする
activity.appState.setBusyBoost(accessInfo, statusArg)
activity.showColumnMatchAccount(accessInfo)
val result =
activity.runApiTask(
accessInfo,
progressStyle = ApiTask.PROGRESS_NONE
) { client ->
try {
val targetStatus = syncStatus(client)
boostApi(client, targetStatus)
} catch (ex: TootApiResultException) {
ex.result
}
}
// ブースト表示を更新中にする
activity.appState.setBusyBoost(accessInfo, statusArg)
activity.showColumnMatchAccount(accessInfo)
try {
activity.runApiTask2(
accessInfo = accessInfo,
progressStyle = ApiTask.PROGRESS_NONE
) { client ->
boostApi(client, syncStatus(client))
}
// カラムデータの書き換え
after(resultStatus, resultUnrenoteId)
} finally {
// 更新中状態をリセット
activity.appState.resetBusyBoost(accessInfo, statusArg)
// カラムデータの書き換え
after(result, resultStatus, resultUnrenoteId)
// result == null の場合でも更新中表示の解除が必要になる
// 失敗やキャンセルの場合でも表示を更新する
activity.showColumnMatchAccount(accessInfo)
}
}

View File

@ -3,15 +3,19 @@ package jp.juggler.subwaytooter.action
import jp.juggler.subwaytooter.ActKeywordFilter
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.ApiPath
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.TootApiResultException
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.TootFilter
import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.api.runApiTask2
import jp.juggler.subwaytooter.column.onFilterDeleted
import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm
import jp.juggler.subwaytooter.dialog.actionsDialog
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.log.showToast
import okhttp3.Request
// private val log = LogCategory("Action_Filter")
@ -30,39 +34,56 @@ fun ActMain.openFilterMenu(accessInfo: SavedAccount, item: TootFilter?) {
}
}
fun ActMain.filterDelete(
accessInfo: SavedAccount,
filter: TootFilter,
bConfirmed: Boolean = false,
) {
launchAndShowError {
if (!bConfirmed) {
confirm(R.string.filter_delete_confirm, filter.displayString)
}
var resultFilterList: List<TootFilter>? = null
runApiTask(accessInfo) { client ->
var result =
client.request("/api/v1/filters/${filter.id}", Request.Builder().delete())
if (result != null && result.error == null) {
result = client.request("/api/v1/filters")
val jsonArray = result?.jsonArray
if (jsonArray != null) resultFilterList = TootFilter.parseList(jsonArray)
}
result
}?.let { result ->
when (val filterList = resultFilterList) {
null -> showToast(false, result.error)
else -> {
showToast(false, R.string.delete_succeeded)
for (column in appState.columnList) {
if (column.accessInfo == accessInfo) {
column.onFilterDeleted(filter, filterList)
}
}
}
suspend fun TootApiClient.filterDelete(filterId: EntityId): TootApiResult {
for (path in arrayOf(
"/api/v2/filters/${filterId}",
"/api/v1/filters/${filterId}",
)) {
try {
return requestOrThrow(path = path)
} catch (ex: TootApiResultException) {
when (ex.result?.response?.code) {
404 -> continue
else -> throw ex
}
}
}
error("missing filter APIs.")
}
suspend fun TootApiClient.filterLoad(): List<TootFilter> {
for (path in arrayOf(
ApiPath.PATH_FILTERS_V2,
ApiPath.PATH_FILTERS_V1,
)) {
try {
val jsonArray = requestOrThrow(path).jsonArray
?: error("API response has no jsonArray.")
return TootFilter.parseList(jsonArray)
?: error("TootFilter.parseList returns null.")
} catch (ex: TootApiResultException) {
when (ex.result?.response?.code) {
404 -> continue
else -> throw ex
}
}
}
error("missing filter APIs.")
}
fun ActMain.filterDelete(
accessInfo: SavedAccount,
filter: TootFilter,
) = launchAndShowError {
confirm(R.string.filter_delete_confirm, filter.displayString)
val newFilters = runApiTask2(accessInfo) { client ->
client.filterDelete(filter.id)
client.filterLoad()
}
showToast(false, R.string.delete_succeeded)
for (column in appState.columnList) {
if (column.accessInfo == accessInfo) {
column.onFilterDeleted(filter, newFilters)
}
}
}

View File

@ -20,8 +20,24 @@ import android.widget.ListView
import android.widget.TextView
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import jp.juggler.subwaytooter.*
import jp.juggler.subwaytooter.action.*
import jp.juggler.subwaytooter.ActAbout
import jp.juggler.subwaytooter.ActAppSetting
import jp.juggler.subwaytooter.ActFavMute
import jp.juggler.subwaytooter.ActHighlightWordList
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.ActMutedApp
import jp.juggler.subwaytooter.ActMutedPseudoAccount
import jp.juggler.subwaytooter.ActMutedWord
import jp.juggler.subwaytooter.ActOSSLicense
import jp.juggler.subwaytooter.ActPushMessageList
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.action.accountAdd
import jp.juggler.subwaytooter.action.accountOpenSetting
import jp.juggler.subwaytooter.action.openColumnFromUrl
import jp.juggler.subwaytooter.action.openColumnList
import jp.juggler.subwaytooter.action.serverProfileDirectoryFromSideMenu
import jp.juggler.subwaytooter.action.timeline
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.dialog.pickAccount
@ -50,7 +66,7 @@ import jp.juggler.util.ui.createColoredDrawable
import kotlinx.coroutines.withContext
import org.jetbrains.anko.backgroundColor
import java.lang.ref.WeakReference
import java.util.*
import java.util.TimeZone
import java.util.concurrent.TimeUnit
import kotlin.math.abs
@ -71,7 +87,7 @@ class SideMenuAdapter(
private const val urlOlderDevices =
"https://github.com/tateisu/SubwayTooter/discussions/192"
private val itemTypeCount = ItemType.values().size
private val itemTypeCount = ItemType.entries.size
private var lastVersionView: WeakReference<TextView>? = null
@ -487,10 +503,12 @@ class SideMenuAdapter(
when (itemType) {
ItemType.IT_DIVIDER ->
viewOrInflate(view, parent, R.layout.lv_sidemenu_separator)
ItemType.IT_GROUP_HEADER ->
viewOrInflate<TextView>(view, parent, R.layout.lv_sidemenu_group).apply {
text = actMain.getString(title)
}
ItemType.IT_NORMAL ->
viewOrInflate<TextView>(view, parent, R.layout.lv_sidemenu_item).apply {
isAllCaps = false
@ -522,6 +540,7 @@ class SideMenuAdapter(
background = null
text = versionText
}
ItemType.IT_TIMEZONE ->
viewOrInflate<TextView>(view, parent, R.layout.lv_sidemenu_item).apply {
textSize = 14f
@ -529,6 +548,7 @@ class SideMenuAdapter(
background = null
text = getTimeZoneString(context)
}
ItemType.IT_NOTIFICATION_PERMISSION ->
viewOrInflate<TextView>(view, parent, R.layout.lv_sidemenu_item).apply {
isAllCaps = false
@ -599,6 +619,7 @@ class SideMenuAdapter(
Pair(R.string.notification_push_distributor_disabled) {
actMain.selectPushDistributor()
}
else -> null
}
@ -616,6 +637,7 @@ class SideMenuAdapter(
else -> true
}
else -> true
}
}

View File

@ -41,7 +41,7 @@ object ApiPath {
// リストではなくオブジェクトを返すAPI
const val PATH_STATUSES = "/api/v1/statuses/%s" // 1:status_id
const val PATH_FILTERS = "/api/v1/filters"
const val PATH_FILTERS_V1 = "/api/v1/filters"
const val PATH_FILTERS_V2 = "/api/v2/filters"
const val PATH_MISSKEY_PROFILE_FOLLOWING = "/api/users/following"

View File

@ -5,16 +5,42 @@ import android.net.Uri
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.auth.AuthBase
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.subwaytooter.api.entity.Host
import jp.juggler.subwaytooter.api.entity.TootAccount
import jp.juggler.subwaytooter.api.entity.TootAccountRef
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRefOrNull
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.api.entity.TootResults
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.*
import jp.juggler.util.data.*
import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.subwaytooter.util.LinkHelper
import jp.juggler.subwaytooter.util.SimpleHttpClient
import jp.juggler.subwaytooter.util.SimpleHttpClientImpl
import jp.juggler.subwaytooter.util.matchHost
import jp.juggler.util.data.CharacterGroup
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.asciiRegex
import jp.juggler.util.data.decodeJsonArray
import jp.juggler.util.data.decodeJsonObject
import jp.juggler.util.data.decodePercent
import jp.juggler.util.data.decodeUTF8
import jp.juggler.util.data.encodePercent
import jp.juggler.util.data.groupEx
import jp.juggler.util.data.letNotEmpty
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.withCaption
import jp.juggler.util.network.toPostRequestBuilder
import okhttp3.*
import kotlinx.coroutines.CancellationException
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.internal.closeQuietly
class TootApiClient(
@ -358,7 +384,7 @@ class TootApiClient(
//////////////////////////////////////////////////////////////////////
// fun request(
// fun request(
// path: String,
// request_builder: Request.Builder = Request.Builder()
// ): TootApiResult? {
@ -391,6 +417,26 @@ class TootApiClient(
// }
//
/**
* requestと同じだがキャンセルやエラー発生時に例外を投げる
*/
suspend fun requestOrThrow(
path: String,
requestBuilder: Request.Builder = Request.Builder(),
forceAccessToken: String? = null,
): TootApiResult {
val result = request(
path = path,
requestBuilder = requestBuilder,
forceAccessToken = forceAccessToken,
)
when {
result == null -> throw CancellationException()
!result.error.isNullOrBlank() -> errorApiResult(result)
else -> return result
}
}
suspend fun request(
path: String,
requestBuilder: Request.Builder = Request.Builder(),

View File

@ -1,12 +1,18 @@
package jp.juggler.subwaytooter.api
import android.content.Context
import androidx.annotation.StringRes
class TootApiResultException(val result: TootApiResult?) :
Exception(result?.error ?: "cancelled.") {
constructor(error: String) : this(TootApiResult(error))
}
fun errorApiResult(result: TootApiResult?): Nothing =
fun errorApiResult(result: TootApiResult): Nothing =
throw TootApiResultException(result)
fun errorApiResult(error: String): Nothing =
throw TootApiResultException(error)
fun Context.errorApiResult(@StringRes stringId: Int, vararg args: Any?): Nothing =
errorApiResult(getString(stringId, *args))

View File

@ -2,7 +2,13 @@ package jp.juggler.subwaytooter.api.entity
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.util.data.*
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.addTo
import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.clip
import jp.juggler.util.data.mayUri
import jp.juggler.util.data.notBlank
import jp.juggler.util.data.notEmpty
@Suppress("LongParameterList")
class TootAttachment private constructor(
@ -92,7 +98,7 @@ class TootAttachment private constructor(
private val ext_audio = arrayOf(".mpga", ".mp3", ".aac", ".ogg")
private fun parseType(src: String?) =
TootAttachmentType.values().find { it.id == src }
TootAttachmentType.entries.find { it.id == src }
private fun guessMediaTypeByUrl(src: String?): TootAttachmentType? {
val uri = src.mayUri() ?: return null
@ -104,6 +110,9 @@ class TootAttachment private constructor(
return null
}
/**
* アプリ内でencodeJson()した情報をデコードする
*/
fun tootAttachmentJson(
src: JsonObject,
): TootAttachment {
@ -147,8 +156,9 @@ class TootAttachment private constructor(
else -> TootAttachmentType.Unknown
}
val url = src.string("url")
val description = src.string("comment")?.notBlank()
?: src.string("name")?.notBlank()
val description = (src.string("comment")?.notBlank()
?: src.string("name")?.notBlank())
?.takeIf { it != "null" }
return TootAttachment(
blurhash = null,
description = description,
@ -189,7 +199,7 @@ class TootAttachment private constructor(
return TootAttachment(
blurhash = src.string("blurhash"),
description = src.string("name"),
description = src.string("name")?.notBlank()?.takeIf { it != "null" },
focusX = parseFocusValue(focus, "x"),
focusY = parseFocusValue(focus, "y"),
id = EntityId.DEFAULT,
@ -219,7 +229,8 @@ class TootAttachment private constructor(
return TootAttachment(
blurhash = src.string("blurhash"),
description = src.string("description"),
description = src.string("description")
?.notBlank()?.takeIf { it != "null" },
focusX = parseFocusValue(focus, "x"),
focusY = parseFocusValue(focus, "y"),
id = EntityId.mayDefault(src.string("id")),

View File

@ -23,13 +23,12 @@ enum class TootFilterContext(
companion object {
private val log = LogCategory("TootFilterContext")
private val valuesCache = values()
private val apiNameMap = valuesCache.associateBy { it.apiName }
private val apiNameMap = entries.associateBy { it.apiName }
fun parseBits(src: JsonArray?): Int =
src?.stringList()?.mapNotNull { apiNameMap[it]?.bit }?.sum() ?: 0
fun bitsToNames(mask: Int) =
valuesCache.filter { it.bit.and(mask) != 0 }.map { it.caption_id }
entries.filter { it.bit.and(mask) != 0 }.map { it.caption_id }
}
}

View File

@ -68,6 +68,7 @@ enum class TootVisibility(
LocalPublic, LocalHome -> true
else -> false
}
else -> when (this) {
Public, UnlistedHome -> true
else -> false
@ -109,47 +110,35 @@ enum class TootVisibility(
companion object {
private val log = LogCategory("TootVisivbility")
fun parseMastodon(a: String?): TootVisibility? {
for (v in values()) {
if (v.strMastodon == a) return v
}
return null
}
fun parseMastodon(a: String?) =
entries.find { it.strMastodon == a }
fun parseMisskey(a: String?, localOnly: Boolean = false): TootVisibility? {
for (v in values()) {
if (v.strMisskey == a) {
if (localOnly) {
when (v) {
Public -> return LocalPublic
UnlistedHome -> return LocalHome
PrivateFollowers -> return LocalFollowers
entries.find { it.strMisskey == a }?.let { v ->
if (localOnly) {
when (v) {
Public -> return LocalPublic
UnlistedHome -> return LocalHome
PrivateFollowers -> return LocalFollowers
else -> {
}
}
else -> Unit
}
return v
}
return v
}
return null
}
fun fromId(id: Int): TootVisibility? {
for (v in values()) {
if (v.id == id) return v
}
return null
}
fun fromId(id: Int) = entries.find { it.id == id }
fun parseSavedVisibility(sv: String?): TootVisibility? {
sv ?: return null
// 新しい方式ではenumのidの文字列表現
values().find { it.id.toString() == sv }?.let { return it }
entries.find { it.id.toString() == sv }?.let { return it }
// 古い方式ではマストドンの公開範囲文字列かweb_setting
values().find { it.strMastodon == sv }?.let { return it }
entries.find { it.strMastodon == sv }?.let { return it }
return null
}

View File

@ -70,7 +70,7 @@ enum class SettingType(val id: Int) {
;
companion object {
val map = values().associateBy { it.id }
val map = entries.associateBy { it.id }
}
}
@ -367,7 +367,7 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett
spinnerSimple(
PrefI.ipAdditionalButtonsPosition,
R.string.additional_buttons_position,
*(AdditionalButtonsPosition.values().sortedBy { it.idx }.map { it.captionId }
*(AdditionalButtonsPosition.entries.sortedBy { it.idx }.map { it.captionId }
.toIntArray())
)
@ -380,7 +380,7 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett
}
section(R.string.translate_or_custom_share) {
CustomShareTarget.values().forEach { target ->
for (target in CustomShareTarget.entries) {
item(
SettingType.TextWithSelector,
target.pref,
@ -498,7 +498,7 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett
sw(PrefB.bpUseInternalMediaViewer, R.string.use_internal_media_viewer)
spinner(PrefI.ipMediaBackground, R.string.background_pattern) {
MediaBackgroundDrawable.Kind.values()
MediaBackgroundDrawable.Kind.entries
.filter { it.isMediaBackground }
.map { it.name }
}

View File

@ -326,7 +326,7 @@ object ColumnEncoder {
-> {
profileId = EntityId.mayNull(src.string(KEY_PROFILE_ID))
val tabId = src.optInt(KEY_PROFILE_TAB)
profileTab = ProfileTab.values().find { it.id == tabId } ?: ProfileTab.Status
profileTab = ProfileTab.entries.find { it.id == tabId } ?: ProfileTab.Status
}
ColumnType.LIST_MEMBER,

View File

@ -8,7 +8,6 @@ import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.table.*
import jp.juggler.util.*
import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.WordTrieTree
import jp.juggler.util.log.LogCategory
@ -433,7 +432,7 @@ suspend fun Column.loadFilter2(client: TootApiClient): List<TootFilter>? {
if (getFilterContext() == null) return null
var result = client.request(ApiPath.PATH_FILTERS_V2)
if (result?.response?.code == 404) {
result = client.request(ApiPath.PATH_FILTERS)
result = client.request(ApiPath.PATH_FILTERS_V1)
}
val jsonArray = result?.jsonArray ?: return null
@ -511,7 +510,7 @@ fun reloadFilter(context: Context, accessInfo: SavedAccount) {
) { client ->
var result = client.request(ApiPath.PATH_FILTERS_V2)
if (result?.response?.code == 404) {
result = client.request(ApiPath.PATH_FILTERS)
result = client.request(ApiPath.PATH_FILTERS_V1)
}
result?.jsonArray?.let {
resultList = TootFilter.parseList(it)

View File

@ -10,7 +10,6 @@ import jp.juggler.subwaytooter.columnviewholder.scrollToTop
import jp.juggler.subwaytooter.notification.injectData
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.util.OpenSticker
import jp.juggler.util.*
import jp.juggler.util.coroutine.runOnMainLooper
import jp.juggler.util.coroutine.runOnMainLooperDelayed
import jp.juggler.util.data.JsonArray
@ -902,7 +901,7 @@ class ColumnTask_Loading(
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)
result = client.request(ApiPath.PATH_FILTERS_V1)
}
if (result != null) {
val src = TootFilter.parseList(result.jsonArray)

View File

@ -6,9 +6,22 @@ import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.ApiPath
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.subwaytooter.api.entity.Host
import jp.juggler.subwaytooter.api.entity.TimelineItem
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRef
import jp.juggler.subwaytooter.api.finder.*
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.api.entity.TootMessageHolder
import jp.juggler.subwaytooter.api.entity.TootNotification
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.api.finder.mastodonFollowSuggestion2ListParser
import jp.juggler.subwaytooter.api.finder.misskey11FollowersParser
import jp.juggler.subwaytooter.api.finder.misskey11FollowingParser
import jp.juggler.subwaytooter.api.finder.misskeyArrayFinderUsers
import jp.juggler.subwaytooter.api.finder.misskeyCustomParserBlocks
import jp.juggler.subwaytooter.api.finder.misskeyCustomParserFavorites
import jp.juggler.subwaytooter.api.finder.misskeyCustomParserFollowRequest
import jp.juggler.subwaytooter.api.finder.misskeyCustomParserMutes
import jp.juggler.subwaytooter.search.MspHelper.loadingMSP
import jp.juggler.subwaytooter.search.MspHelper.refreshMSP
import jp.juggler.subwaytooter.search.NotestockHelper.loadingNotestock
@ -17,10 +30,16 @@ import jp.juggler.subwaytooter.search.TootsearchHelper.loadingTootsearch
import jp.juggler.subwaytooter.search.TootsearchHelper.refreshTootsearch
import jp.juggler.subwaytooter.streaming.StreamSpec
import jp.juggler.subwaytooter.table.daoAcctColor
import jp.juggler.util.*
import jp.juggler.util.data.*
import jp.juggler.util.data.JsonArray
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.appendIf
import jp.juggler.util.data.ellipsizeDot3
import jp.juggler.util.data.jsonArrayOf
import jp.juggler.util.data.jsonObjectOf
import jp.juggler.util.data.notEmpty
import jp.juggler.util.data.toJsonArray
import jp.juggler.util.log.LogCategory
import java.util.*
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
@ -1128,6 +1147,7 @@ enum class ColumnType(
arrayFinder = misskeyArrayFinderUsers,
listParser = misskeyCustomParserMutes
)
else -> getAccountList(client, ApiPath.PATH_MUTES)
}
},
@ -1142,6 +1162,7 @@ enum class ColumnType(
arrayFinder = misskeyArrayFinderUsers,
listParser = misskeyCustomParserMutes
)
else -> getAccountList(
client,
ApiPath.PATH_MUTES,
@ -1750,6 +1771,7 @@ enum class ColumnType(
ApiPath.PATH_FOLLOW_SUGGESTION2,
listParser = mastodonFollowSuggestion2ListParser,
)
else ->
getAccountList(client, ApiPath.PATH_FOLLOW_SUGGESTION)
}
@ -1773,6 +1795,7 @@ enum class ColumnType(
ApiPath.PATH_FOLLOW_SUGGESTION2,
listParser = mastodonFollowSuggestion2ListParser,
)
else ->
getAccountList(client, ApiPath.PATH_FOLLOW_SUGGESTION)
}
@ -1798,6 +1821,7 @@ enum class ColumnType(
listParser = mastodonFollowSuggestion2ListParser,
mastodonFilterByIdRange = false
)
else ->
getAccountList(
client,
@ -2088,7 +2112,7 @@ enum class ColumnType(
fun dump() {
var min = Int.MAX_VALUE
var max = Int.MIN_VALUE
values().forEach {
for (it in entries) {
val id = it.id
min = min(min, id)
max = max(max, id)

View File

@ -3,7 +3,9 @@ package jp.juggler.subwaytooter.dialog
import android.app.Dialog
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.widget.*
import android.widget.ArrayAdapter
import android.widget.Filter
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.addTextChangedListener
@ -21,15 +23,23 @@ import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.notBlank
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.*
import jp.juggler.util.ui.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.ui.ProgressDialogEx
import jp.juggler.util.ui.attrColor
import jp.juggler.util.ui.dismissSafe
import jp.juggler.util.ui.hideKeyboard
import jp.juggler.util.ui.invisible
import jp.juggler.util.ui.isEnabledAlpha
import jp.juggler.util.ui.vg
import jp.juggler.util.ui.visible
import jp.juggler.util.ui.visibleOrInvisible
import kotlinx.coroutines.withContext
import org.jetbrains.anko.textColor
import org.jetbrains.anko.textResource
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.IDN
import java.util.*
class LoginForm(
val activity: AppCompatActivity,
@ -62,7 +72,8 @@ class LoginForm(
) {
Login(R.string.existing_account, R.string.existing_account_desc),
Pseudo(R.string.pseudo_account, R.string.pseudo_account_desc),
// Create(2, R.string.create_account, R.string.create_account_desc),
// Create(2, R.string.create_account, R.string.create_account_desc),
Token(R.string.input_access_token, R.string.input_access_token_desc),
}
@ -76,7 +87,7 @@ class LoginForm(
private var targetServerInfo: TootInstance? = null
init {
for (a in Action.values()) {
for (a in Action.entries) {
val subViews =
LvAuthTypeBinding.inflate(activity.layoutInflater, views.llPageAuthType, true)
subViews.btnAuthType.textResource = a.idName

View File

@ -1,10 +1,15 @@
package jp.juggler.subwaytooter.drawable
import android.content.Context
import android.graphics.*
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.drawable.Drawable
import jp.juggler.subwaytooter.R
import jp.juggler.util.ui.*
import jp.juggler.util.ui.attrColor
import kotlin.math.min
class MediaBackgroundDrawable(
@ -33,10 +38,10 @@ class MediaBackgroundDrawable(
;
fun toIndex() = values().indexOf(this)
fun toIndex() = entries.indexOf(this)
companion object {
fun fromIndex(idx: Int) = values().elementAtOrNull(idx) ?: BlackTile
fun fromIndex(idx: Int) = entries.elementAtOrNull(idx) ?: BlackTile
}
}

View File

@ -20,9 +20,7 @@ class EmojiMapLoader(
private val assetsSet = appContext.assets.list("")!!.toSet()
private val resources = appContext.resources!!
private val categoryNameMap = HashMap<String, EmojiCategory>().apply {
EmojiCategory.values().forEach { put(it.name, it) }
}
private val categoryNameMap = EmojiCategory.entries.associateBy { it.name }
private var lastEmoji: UnicodeEmoji? = null
private var lastCategory: EmojiCategory? = null
@ -60,28 +58,34 @@ class EmojiMapLoader(
if (!assetsSet.contains(line)) error("missing assets.")
lastEmoji = UnicodeEmoji(assetsName = line)
}
"drawable" -> {
val drawableId = getDrawableId(line) ?: error("missing drawable.")
lastEmoji = UnicodeEmoji(drawableId = drawableId)
}
"un" -> {
val emoji = lastEmoji ?: error("missing lastEmoji.")
addCode(emoji, line)
emoji.unifiedCode = line
}
"u" -> {
val emoji = lastEmoji ?: error("missing lastEmoji.")
addCode(emoji, line)
}
"sn" -> {
val emoji = lastEmoji ?: error("missing lastEmoji.")
addName(emoji, line)
emoji.unifiedName = line
}
"s" -> {
val emoji = lastEmoji ?: error("missing lastEmoji.")
addName(emoji, line)
}
"t" -> {
val cols = line.split(",", limit = 3)
if (cols.size != 3) error("invalid tone spec. line=$lno $line")
@ -99,6 +103,7 @@ class EmojiMapLoader(
lastCategory = categoryNameMap[line]
?: error("missing category name.")
}
"c" -> {
val category = lastCategory
?: error("missing lastCategory.")
@ -110,6 +115,7 @@ class EmojiMapLoader(
category.emojiList.add(emoji)
}
}
else -> error("unknown header $head")
}
} catch (ex: Throwable) {

View File

@ -76,7 +76,7 @@ enum class AdditionalButtonsPosition(
;
companion object {
fun fromIndex(i: Int) = values().find { it.idx == i } ?: Top
fun fromIndex(i: Int) = entries.find { it.idx == i } ?: Top
}
}
@ -821,7 +821,7 @@ class StatusButtonsViewHolder(
}
private fun AnkoFlexboxLayout.additionalButtons() {
btnCustomShares = CustomShareTarget.values().map { target ->
btnCustomShares = CustomShareTarget.entries.map { target ->
imageButton {
background = ContextCompat.getDrawable(
context,

View File

@ -256,10 +256,9 @@ class NotificationChannelsInitializer : Initializer<Boolean> {
override fun create(context: Context): Boolean {
context.run {
val list = NotificationChannels.values()
log.i("createNotificationChannel(s) size=${list.size}")
val notificationManager = NotificationManagerCompat.from(this)
for (nc in list) {
log.i("createNotificationChannel(s) size=${NotificationChannels.entries.size}")
for (nc in NotificationChannels.entries) {
val channel = NotificationChannel(
nc.id,
getString(nc.titleId),

View File

@ -10,9 +10,4 @@ enum class PollingState(val desc: String) {
CheckServerInformation("check server information"),
CheckPushSubscription("check push subscription"),
CheckNotifications("check notifications"),
;
companion object {
val valuesCache = values()
}
}

View File

@ -4,7 +4,17 @@ import android.app.ActivityManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.work.*
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.await
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R
@ -114,7 +124,7 @@ class PollingWorker2(
private fun stateMapToString(map: Map<PollingState, List<String>>) =
StringBuilder().apply {
for (state in PollingState.valuesCache) {
for (state in PollingState.entries) {
val list = map[state] ?: continue
if (isNotEmpty()) append(" |")
append(state.desc)

View File

@ -9,7 +9,6 @@ enum class TrackingType(
NotReply("notReply", PullNotification.TRACKING_NAME_DEFAULT);
companion object {
private val valuesCache = values()
fun parseStr(str: String?) = valuesCache.firstOrNull { it.str == str } ?: All
fun parseStr(str: String?) = entries.firstOrNull { it.str == str } ?: All
}
}

View File

@ -7,99 +7,102 @@ import jp.juggler.subwaytooter.api.entity.TootNotification
import jp.juggler.subwaytooter.table.PushMessage
import jp.juggler.util.log.LogCategory
private val log = LogCategory("NotificationIconAndColor")
private val log = LogCategory("PushMessageIconColor")
enum class PushMessageIconColor(
@ColorRes val colorRes: Int,
@DrawableRes val iconId: Int,
val keys: Array<String>,
val keys: Set<String>,
) {
Favourite(
0,
R.drawable.ic_star_outline,
arrayOf("favourite"),
setOf("favourite"),
),
Mention(
0,
R.drawable.outline_alternate_email_24,
arrayOf("mention"),
setOf("mention"),
),
Reply(
0,
R.drawable.ic_reply,
arrayOf("reply")
setOf("reply")
),
Reblog(
0,
R.drawable.ic_repeat,
arrayOf("reblog", "renote"),
setOf("reblog", "renote"),
),
Quote(
0,
R.drawable.ic_quote,
arrayOf("quote"),
setOf("quote"),
),
Follow(
0,
R.drawable.ic_person_add,
arrayOf("follow", "followRequestAccepted")
setOf("follow", "followRequestAccepted")
),
Unfollow(
0,
R.drawable.ic_follow_cross,
arrayOf("unfollow")
setOf("unfollow")
),
Reaction(
0,
R.drawable.outline_add_reaction_24,
arrayOf("reaction", "emoji_reaction", "pleroma:emoji_reaction")
setOf("reaction", "emoji_reaction", "pleroma:emoji_reaction")
),
FollowRequest(
R.color.colorNotificationAccentFollowRequest,
R.drawable.ic_follow_wait,
arrayOf("follow_request", "receiveFollowRequest"),
setOf("follow_request", "receiveFollowRequest"),
),
Poll(
0,
R.drawable.outline_poll_24,
arrayOf("pollVote", "poll_vote", "poll"),
setOf("pollVote", "poll_vote", "poll"),
),
Status(
0,
R.drawable.ic_edit,
arrayOf("status", "update", "status_reference")
setOf("status", "update", "status_reference")
),
AdminSignUp(
0,
R.drawable.outline_group_add_24,
arrayOf(TootNotification.TYPE_ADMIN_SIGNUP),
setOf(TootNotification.TYPE_ADMIN_SIGNUP),
),
AdminReport(
R.color.colorNotificationAccentAdminReport,
R.drawable.ic_error,
arrayOf(TootNotification.TYPE_ADMIN_REPORT),
setOf(TootNotification.TYPE_ADMIN_REPORT),
),
Unknown(
R.color.colorNotificationAccentUnknown,
R.drawable.ic_question,
arrayOf("unknown"),
setOf("unknown"),
)
;
companion object {
val map = buildMap {
values().forEach {
for (k in it.keys) {
val old: PushMessageIconColor? = get(k)
if (old != null) {
error("NotificationIconAndColor: $k is duplicate: ${it.name} and ${old.name}")
} else {
put(k, it)
}
val map = PushMessageIconColor.entries.map { it.keys }.flatten().toSet()
.associateWith { key ->
val colors = PushMessageIconColor.entries
.filter { it.keys.contains(key) }
when {
colors.isEmpty() -> error("missing color fot key=$key")
colors.size > 1 -> error(
"NotificationIconAndColor: duplicate key $key to ${
colors.joinToString(", ") { it.name }
}"
)
else -> colors.first()
}
}
}
}
}

View File

@ -208,7 +208,7 @@ object CustomShare {
fun getCache(target: CustomShareTarget) = cache[target]
fun reloadCache(context: Context) {
CustomShareTarget.values().forEach { target ->
for (target in CustomShareTarget.entries) {
val cn = target.customShareComponentName
val pair = getInfo(context, cn)
cache[target] = pair

View File

@ -3,8 +3,20 @@ package jp.juggler.subwaytooter.util
import android.os.SystemClock
import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResultException
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.InstanceCapability
import jp.juggler.subwaytooter.api.entity.InstanceType
import jp.juggler.subwaytooter.api.entity.TootAccount
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.api.entity.TootPollsType
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.api.entity.TootTag
import jp.juggler.subwaytooter.api.entity.TootVisibility
import jp.juggler.subwaytooter.api.errorApiResult
import jp.juggler.subwaytooter.api.runApiTask2
import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm
import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.subwaytooter.getVisibilityString
@ -14,19 +26,31 @@ import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.daoAcctColor
import jp.juggler.subwaytooter.table.daoSavedAccount
import jp.juggler.subwaytooter.table.daoTagHistory
import jp.juggler.util.*
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.data.*
import jp.juggler.util.log.*
import jp.juggler.util.data.JsonArray
import jp.juggler.util.data.JsonException
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.buildJsonArray
import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.digestSHA256Hex
import jp.juggler.util.data.groupEx
import jp.juggler.util.data.jsonObjectOf
import jp.juggler.util.data.notEmpty
import jp.juggler.util.data.toJsonArray
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.errorString
import jp.juggler.util.log.showToast
import jp.juggler.util.network.MEDIA_TYPE_JSON
import jp.juggler.util.network.toPostRequestBuilder
import jp.juggler.util.ui.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.*
import java.util.Calendar
import java.util.GregorianCalendar
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.atomic.AtomicBoolean
sealed class PostResult {
@ -110,36 +134,22 @@ class PostImpl(
}
}
private var resultStatus: TootStatus? = null
private var resultCredentialTmp: TootAccount? = null
private var resultScheduledStatusSucceeded = false
private suspend fun getCredential(
client: TootApiClient,
parser: TootParser,
): TootApiResult? {
return client.request("/api/v1/accounts/verify_credentials")?.also { result ->
resultCredentialTmp = parser.account(result.jsonObject)
}
}
// may null, not error
private suspend fun getWebVisibility(
client: TootApiClient,
parser: TootParser,
instance: TootInstance,
): TootVisibility? {
if (account.isMisskey || instance.versionGE(TootInstance.VERSION_1_6)) return null
val r2 = getCredential(client, parser)
val credentialTmp = resultCredentialTmp
?: errorApiResult(r2)
val privacy = credentialTmp.source?.privacy
?: errorApiResult(activity.getString(R.string.cant_get_web_setting_visibility))
return TootVisibility.parseMastodon(privacy)
// may null, not error
): TootVisibility? = when {
account.isMisskey -> null
instance.versionGE(TootInstance.VERSION_1_6) -> null
else -> {
val privacy = parser.account(
client.requestOrThrow("/api/v1/accounts/verify_credentials")
.jsonObject
)?.source?.privacy
?: error(R.string.cant_get_web_setting_visibility)
TootVisibility.parseMastodon(privacy)
}
}
private fun checkServerHasVisibility(
@ -150,12 +160,7 @@ class PostImpl(
) {
if (actual != extra || checkFun(instance)) return
val strVisibility = extra.getVisibilityString(account.isMisskey)
errorApiResult(
activity.getString(
R.string.server_has_no_support_of_visibility,
strVisibility
)
)
activity.errorApiResult(R.string.server_has_no_support_of_visibility, strVisibility)
}
private suspend fun checkVisibility(
@ -205,7 +210,6 @@ class PostImpl(
"/api/v1/scheduled_statuses/$scheduledId",
Request.Builder().delete()
)
log.d("delete old scheduled status. result=$result")
delay(2000L)
}
@ -271,15 +275,13 @@ class PostImpl(
// Misskeyの場合、NSFWするにはアップロード済みの画像を drive/files/update で更新する
if (bNSFW) {
val r = client.request(
client.requestOrThrow(
"/api/drive/files/update",
account.putMisskeyApiToken().apply {
put("fileId", a.id.toString())
put("isSensitive", true)
}
.toPostRequestBuilder()
}.toPostRequestBuilder()
)
if (r == null || r.error != null) errorApiResult(r)
}
}
if (array.isNotEmpty()) json["mediaIds"] = array
@ -361,7 +363,7 @@ class PostImpl(
if (scheduledAt != 0L) {
if (!instance.versionGE(TootInstance.VERSION_2_7_0_rc1)) {
errorApiResult(activity.getString(R.string.scheduled_status_requires_mastodon_2_7_0))
activity.errorApiResult(R.string.scheduled_status_requires_mastodon_2_7_0)
}
// UTCの日時を渡す
val c = GregorianCalendar.getInstance(TimeZone.getTimeZone("UTC"))
@ -505,32 +507,28 @@ class PostImpl(
isPosting.set(true)
return try {
withContext(AppDispatchers.MainImmediate) {
activity.runApiTask(
account,
val (status, scheduled) = activity.runApiTask2(
accessInfo = account,
progressSetup = { it.setCanceledOnTouchOutside(false) },
) { client ->
val (instance, ri) = TootInstance.get(client)
instance ?: return@runApiTask ri
val instance = TootInstance.getOrThrow(client)
if (instance.instanceType == InstanceType.Pixelfed) {
// Pixelfedは返信に画像を添付できない
if (inReplyToId != null && attachmentList != null) {
return@runApiTask TootApiResult(getString(R.string.pixelfed_does_not_allow_reply_with_media))
error(R.string.pixelfed_does_not_allow_reply_with_media)
}
// Pixelfedの返信ではない投稿は画像添付が必須
if (inReplyToId == null && attachmentList == null) {
return@runApiTask TootApiResult(getString(R.string.pixelfed_does_not_allow_post_without_media))
error(R.string.pixelfed_does_not_allow_post_without_media)
}
}
val parser = TootParser(this, account)
this@PostImpl.visibilityChecked = try {
checkVisibility(client, parser, instance) // may null
} catch (ex: TootApiResultException) {
return@runApiTask ex.result
}
// may null
this@PostImpl.visibilityChecked = checkVisibility(client, parser, instance)
if (redraftStatusId != null) {
// 元の投稿を削除する
@ -546,10 +544,8 @@ class PostImpl(
} else {
encodeParamsMastodon(json, instance)
}
} catch (ex: TootApiResultException) {
return@runApiTask ex.result
} catch (ex: JsonException) {
log.e(ex, "status encoding failed.")
throw IllegalStateException("encoding status failed.", ex)
}
val bodyString = json.toString()
@ -567,67 +563,66 @@ class PostImpl(
}
}
when {
account.isMisskey -> client.request(
"/api/notes/create",
createRequestBuilder()
)
try {
val result = when {
account.isMisskey -> client.requestOrThrow(
"/api/notes/create",
createRequestBuilder()
)
editStatusId != null -> client.request(
"/api/v1/statuses/$editStatusId",
createRequestBuilder(isPut = true)
)
editStatusId != null -> client.requestOrThrow(
"/api/v1/statuses/$editStatusId",
createRequestBuilder(isPut = true)
)
else -> client.request(
"/api/v1/statuses",
createRequestBuilder()
)
}?.also { result ->
else -> client.requestOrThrow(
"/api/v1/statuses",
createRequestBuilder()
)
}
val jsonObject = result.jsonObject
when {
if (scheduledAt != 0L && jsonObject != null) {
// {"id":"3","scheduled_at":"2019-01-06T07:08:00.000Z","media_attachments":[]}
resultScheduledStatusSucceeded = true
return@runApiTask result
} else {
val status = parser.status(
when {
account.isMisskey -> jsonObject?.jsonObject("createdNote")
?: jsonObject
// 予約投稿完了
scheduledAt != 0L && jsonObject != null -> {
// {"id":"3","scheduled_at":"2019-01-06T07:08:00.000Z","media_attachments":[]}
Pair(null, true)
}
else -> jsonObject
}
)
resultStatus = status
saveStatusTag(status)
// 通常投稿完了
else -> {
val status = parser.status(
when {
account.isMisskey ->
jsonObject?.jsonObject("createdNote")
?: jsonObject
else -> jsonObject
}
)?.also { saveStatusTag(it) }
Pair(status, false)
}
}
} catch (ex: TootApiResultException) {
val errorMessage = ex.result?.error
when {
errorMessage.isNullOrBlank() -> error("(missing error detail)")
errorMessage.contains("HTTP 404") ->
error("$ex\n${activity.getString(R.string.post_404_desc)}")
else -> throw ex
}
}
}.let { result ->
if (result == null) throw CancellationException()
}
when {
scheduled -> PostResult.Scheduled(account)
val status = resultStatus
when {
resultScheduledStatusSucceeded ->
PostResult.Scheduled(account)
status == null ->
error("can't parse status in API result.")
// 連投してIdempotency が同じだった場合もエラーにはならず、ここを通る
status != null ->
PostResult.Normal(account, status)
else -> {
val e = result.error
error(
when {
e.isNullOrBlank() -> "(missing error detail)"
e.contains("HTTP 404") ->
"$e\n${activity.getString(R.string.post_404_desc)}"
else -> e
}
)
}
}
// 連投してIdempotency が同じだった場合もエラーにはならず、ここを通る
else -> PostResult.Normal(account, status)
}
}
} finally {

View File

@ -7,7 +7,12 @@ import jp.juggler.util.log.showError
import jp.juggler.util.log.showToast
import jp.juggler.util.ui.ProgressDialogEx
import jp.juggler.util.ui.dismissSafe
import kotlinx.coroutines.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
@ -68,6 +73,7 @@ fun AppCompatActivity.launchAndShowError(
is CancellationException -> {
log.w(errorCaption ?: "launchAndShowError cancelled.")
}
else -> {
log.e(ex, errorCaption ?: "launchAndShowError failed.")
showError(ex, errorCaption)

View File

@ -9,7 +9,8 @@ import android.util.Base64
import androidx.core.net.toUri
import jp.juggler.util.log.LogCategory
import java.security.MessageDigest
import java.util.*
import java.util.LinkedList
import java.util.Locale
import java.util.regex.Matcher
import java.util.regex.Pattern

View File

@ -1,6 +1,7 @@
package jp.juggler.util.os
import android.content.Context
import androidx.annotation.StringRes
/**
* インストゥルメントテストのContextは
@ -8,4 +9,12 @@ import android.content.Context
* この場合は元のcontextを補うのがベストだろう
*/
val Context.applicationContextSafe: Context
get() = applicationContext ?: this
get() = try {
applicationContext ?: this
} catch (ex: Throwable) {
// applicationContextへのアクセスは例外を出すことがある
this
}
fun Context.error(@StringRes resId: Int, vararg args: Any?): Nothing =
error(getString(resId, *args))

View File

@ -392,18 +392,17 @@ fun AppCompatActivity.setNavigationBack(toolbar: Toolbar) =
onBackPressedDispatcher.onBackPressed()
}
val Float.roundPixels get() = (this + 0.5f).toInt()
fun DisplayMetrics.dpFloat(src: Float) = (density * src)
fun DisplayMetrics.dpFloat(src: Int) = (density * src.toFloat())
fun Resources.dpFloat(src: Float) = displayMetrics.dpFloat(src)
fun Resources.dpFloat(src: Int) = displayMetrics.dpFloat(src)
fun Context.dpFloat(src: Float) = resources.dpFloat(src)
fun Context.dpFloat(src: Int) = resources.dpFloat(src)
val Float.roundPixels get()= (this+0.5f).toInt()
fun DisplayMetrics.dpFloat(src:Float) = (density* src)
fun DisplayMetrics.dpFloat(src:Int) = (density*src.toFloat())
fun Resources.dpFloat(src:Float) = displayMetrics.dpFloat(src)
fun Resources.dpFloat(src:Int) = displayMetrics.dpFloat(src)
fun Context.dpFloat(src:Float) = resources.dpFloat(src)
fun Context.dpFloat(src:Int) = resources.dpFloat(src)
fun DisplayMetrics.dp(src:Float) = (density* src).roundPixels
fun DisplayMetrics.dp(src:Int) = (density*src.toFloat()).roundPixels
fun Resources.dp(src:Float) = displayMetrics.dp(src)
fun Resources.dp(src:Int) = displayMetrics.dp(src)
fun Context.dp(src:Float) = resources.dp(src)
fun Context.dp(src:Int) = resources.dp(src)
fun DisplayMetrics.dp(src: Float) = (density * src).roundPixels
fun DisplayMetrics.dp(src: Int) = (density * src.toFloat()).roundPixels
fun Resources.dp(src: Float) = displayMetrics.dp(src)
fun Resources.dp(src: Int) = displayMetrics.dp(src)
fun Context.dp(src: Float) = resources.dp(src)
fun Context.dp(src: Int) = resources.dp(src)

View File

@ -17,7 +17,7 @@ object Vers {
const val conscryptVersion = "2.5.2"
const val coreKtxVersion = "1.12.0"
const val desugarLibVersion = "2.0.3"
const val detektVersion = "1.23.1"
const val detektVersion = "1.23.4"
const val emoji2Version = "1.4.0"
const val glideVersion = "4.15.1"
const val junitVersion = "4.13.2"

View File

@ -15,7 +15,6 @@ android {
defaultConfig {
minSdk = Vers.stMinSdkVersion
targetSdk = Vers.stTargetSdkVersion
testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true

View File

@ -671,7 +671,7 @@ style:
active: true
OptionalUnit:
active: false
OptionalWhenBraces:
BracesOnWhenStatements:
active: false
PreferToOverPairSyntax:
active: false

View File

@ -13,7 +13,6 @@ android {
defaultConfig {
minSdk = Vers.stMinSdkVersion
targetSdk = Vers.stTargetSdkVersion
vectorDrawables.useSupportLibrary = true
}

View File

@ -13,7 +13,6 @@ android {
defaultConfig {
minSdk = Vers.stMinSdkVersion
targetSdk = Vers.stTargetSdkVersion
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}

View File

@ -31,7 +31,7 @@ android {
)
}
}
packagingOptions {
packaging {
resources {
pickFirsts += listOf("META-INF/atomicfu.kotlin_module")
}