diff --git a/LICENSE.txt b/LICENSE.txt index 10699666..6b7f2363 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -186,7 +186,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2013 Christopher Jenkins + Copyright 2017-2018 tateisu Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/app/build.gradle b/app/build.gradle index 7292d00d..ddfa5131 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { minSdkVersion 21 targetSdkVersion 27 - versionCode 266 - versionName "2.6.6" + versionCode 267 + versionName "2.6.7" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" // https://stackoverflow.com/questions/47791227/java-lang-illegalstateexception-dex-archives-setting-dex-extension-only-for diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ce263801..2c2189a3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -77,6 +77,19 @@ /> + + + + + + + + + diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt b/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt index 010fd2db..859b674f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt @@ -38,8 +38,7 @@ import android.widget.LinearLayout import android.widget.TextView import jp.juggler.subwaytooter.action.* import jp.juggler.subwaytooter.api.* -import jp.juggler.subwaytooter.api.entity.EntityId -import jp.juggler.subwaytooter.api.entity.EntityIdLong +import jp.juggler.subwaytooter.api.entity.* import org.apache.commons.io.IOUtils @@ -52,8 +51,6 @@ import java.util.ArrayList import java.util.HashSet import java.util.regex.Pattern -import jp.juggler.subwaytooter.api.entity.TootAccount -import jp.juggler.subwaytooter.api.entity.TootStatus import jp.juggler.subwaytooter.dialog.AccountPicker import jp.juggler.subwaytooter.dialog.DlgTextInput import jp.juggler.subwaytooter.table.AcctColor @@ -1464,15 +1461,13 @@ class ActMain : AppCompatActivity() // ActOAuthCallbackで受け取ったUriを処理する private fun handleIntentUri(uri : Uri) { - - if("subwaytooter" == uri.scheme) { - try { + + when(uri.scheme){ + "subwaytooter","misskeyclientproto" -> return try{ handleOAuth2CallbackUri(uri) } catch(ex : Throwable) { log.trace(ex) } - - return } val url = uri.toString() @@ -1627,59 +1622,108 @@ class ActMain : AppCompatActivity() override fun background(client : TootApiClient) : TootApiResult? { - // エラー時 - // subwaytooter://oauth - // ?error=access_denied - // &error_description=%E3%83%AA%E3%82%BD%E3%83%BC%E3%82%B9%E3%81%AE%E6%89%80%E6%9C%89%E8%80%85%E3%81%BE%E3%81%9F%E3%81%AF%E8%AA%8D%E8%A8%BC%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%81%8C%E8%A6%81%E6%B1%82%E3%82%92%E6%8B%92%E5%90%A6%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F%E3%80%82 - // &state=db%3A3 - val error = uri.getQueryParameter("error_description") - if(error?.isNotEmpty() == true) { - return TootApiResult(error) - } - - // subwaytooter://oauth - // ?code=113cc036e078ac500d3d0d3ad345cd8181456ab087abc67270d40f40a4e9e3c2 - // &state=host%3Amastodon.juggler.jp - - val code = uri.getQueryParameter("code") - if(code?.isEmpty() != false) { - return TootApiResult("missing code in callback url.") - } - - val sv = uri.getQueryParameter("state") - if(sv?.isEmpty() != false) { - return TootApiResult("missing state in callback url.") - } - - if(sv.startsWith("db:")) { - try { - val dataId = sv.substring(3).toLong(10) - val sa = SavedAccount.loadAccount(this@ActMain, dataId) - ?: return TootApiResult("missing account db_id=$dataId") - this.sa = sa - client.account = sa - } catch(ex : Throwable) { - log.trace(ex) - return TootApiResult(ex.withCaption("invalid state")) + val uriStr = uri.toString() + if( uriStr.startsWith("subwaytooter://misskey/auth_callback") + || uriStr.startsWith("misskeyclientproto://misskeyclientproto/auth_callback") + ){ + + // Misskey 認証コールバック + val token = uri.getQueryParameter("token") + if(token?.isEmpty() != false) { + return TootApiResult("missing token in callback URL") + } + val prefDevice = PrefDevice.prefDevice(this@ActMain) + + val db_id = prefDevice.getLong(PrefDevice.LAST_AUTH_DB_ID,-1L) + + val instance = prefDevice.getString(PrefDevice.LAST_AUTH_INSTANCE,null) + ?: return TootApiResult("missing instance name.") + + if( db_id != -1L ){ + try { + val sa = SavedAccount.loadAccount(this@ActMain, db_id) + ?: return TootApiResult("missing account db_id=$db_id") + this.sa = sa + client.account = sa + } catch(ex : Throwable) { + log.trace(ex) + return TootApiResult(ex.withCaption("invalid state")) + } + }else{ + client.instance = instance } - } else if(sv.startsWith("host:")) { - val host = sv.substring(5) - client.instance = host + this.host = instance + val client_name = Pref.spClientName(this@ActMain) + val result = client.authentication2Misskey(client_name, token) + this.ta = TootParser( + this@ActMain, + object : LinkHelper { + override val host : String? + get() = instance + }, + serviceType = ServiceType.MISSKEY + ).account(result?.jsonObject) + return result + + }else{ + // Mastodon 認証コールバック + + // エラー時 + // subwaytooter://oauth + // ?error=access_denied + // &error_description=%E3%83%AA%E3%82%BD%E3%83%BC%E3%82%B9%E3%81%AE%E6%89%80%E6%9C%89%E8%80%85%E3%81%BE%E3%81%9F%E3%81%AF%E8%AA%8D%E8%A8%BC%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%81%8C%E8%A6%81%E6%B1%82%E3%82%92%E6%8B%92%E5%90%A6%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F%E3%80%82 + // &state=db%3A3 + val error = uri.getQueryParameter("error_description") + if(error?.isNotEmpty() == true) { + return TootApiResult(error) + } + + // subwaytooter://oauth + // ?code=113cc036e078ac500d3d0d3ad345cd8181456ab087abc67270d40f40a4e9e3c2 + // &state=host%3Amastodon.juggler.jp + + val code = uri.getQueryParameter("code") + if(code?.isEmpty() != false) { + return TootApiResult("missing code in callback url.") + } + + val sv = uri.getQueryParameter("state") + if(sv?.isEmpty() != false) { + return TootApiResult("missing state in callback url.") + } + + if(sv.startsWith("db:")) { + try { + val dataId = sv.substring(3).toLong(10) + val sa = SavedAccount.loadAccount(this@ActMain, dataId) + ?: return TootApiResult("missing account db_id=$dataId") + this.sa = sa + client.account = sa + } catch(ex : Throwable) { + log.trace(ex) + return TootApiResult(ex.withCaption("invalid state")) + } + + } else if(sv.startsWith("host:")) { + val host = sv.substring(5) + client.instance = host + } + + val instance = client.instance + ?: return TootApiResult("missing instance in callback url.") + + this.host = instance + val client_name = Pref.spClientName(this@ActMain) + val result = client.authentication2(client_name, code) + this.ta = TootParser(this@ActMain, object : LinkHelper { + override val host : String? + get() = instance + }) + .account(result?.jsonObject) + return result } - val instance = client.instance - ?: return TootApiResult("missing instance in callback url.") - - this.host = instance - val client_name = Pref.spClientName(this@ActMain) - val result = client.authentication2(client_name, code) - this.ta = TootParser(this@ActMain, object : LinkHelper { - override val host : String? - get() = instance - }) - .account(result?.jsonObject) - return result } override fun handleResult(result : TootApiResult?) { @@ -1757,7 +1801,13 @@ class ActMain : AppCompatActivity() // アカウント追加時 val user = ta.username + "@" + host - val row_id = SavedAccount.insert(host, user, jsonObject, token_info) + val row_id = SavedAccount.insert( + host, + user, + jsonObject, + token_info, + isMisskey = token_info.optBoolean(TootApiClient.KEY_IS_MISSKEY) + ) val account = SavedAccount.loadAccount(this@ActMain, row_id) if(account != null) { var bModified = false diff --git a/app/src/main/java/jp/juggler/subwaytooter/App1.kt b/app/src/main/java/jp/juggler/subwaytooter/App1.kt index 588996e2..aeba1950 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/App1.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/App1.kt @@ -102,14 +102,17 @@ class App1 : Application() { // 2018/5/16 v252 25=>26 SubscriptionServerKey テーブルを丸ごと変更 // 2018/8/5 v264 26 => 27 SavedAccountテーブルに項目追加 // 2018/8/17 v267 27 => 28 SavedAccountテーブルに項目追加 - internal const val DB_VERSION = 28 + // 2018/8/19 v267 28 => 29 ContentWarningMisskey, MediaShownMisskey テーブルを追加 + internal const val DB_VERSION = 29 private val tableList = arrayOf( LogData, SavedAccount, ClientInfo, MediaShown, + MediaShownMisskey, ContentWarning, + ContentWarningMisskey, NotificationTracking, MutedApp, UserRelation, diff --git a/app/src/main/java/jp/juggler/subwaytooter/Column.kt b/app/src/main/java/jp/juggler/subwaytooter/Column.kt index e1d98788..91ace5bb 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/Column.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/Column.kt @@ -4590,6 +4590,8 @@ class Column( if(access_info.isMisskey) { if(parser!=null) parser.serviceType = ServiceType.MISSKEY params.put("limit", 100) + val apiKey = access_info.token_info?.parseString(TootApiClient.KEY_API_KEY_MISSKEY) + if( apiKey?.isNotEmpty() == true) params.put("i",apiKey) } return params } diff --git a/app/src/main/java/jp/juggler/subwaytooter/PrefDevice.kt b/app/src/main/java/jp/juggler/subwaytooter/PrefDevice.kt index d8ac3476..782c6645 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/PrefDevice.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/PrefDevice.kt @@ -14,4 +14,7 @@ object PrefDevice { internal const val KEY_DEVICE_TOKEN = "device_token" internal const val KEY_INSTALL_ID = "install_id" + const val LAST_AUTH_INSTANCE="lastAuthInstance" + const val LAST_AUTH_SECRET="lastAuthSecret" + const val LAST_AUTH_DB_ID ="lastAuthDbId" } diff --git a/app/src/main/java/jp/juggler/subwaytooter/StatusButtons.kt b/app/src/main/java/jp/juggler/subwaytooter/StatusButtons.kt index 8be6ea47..2ad9a561 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/StatusButtons.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/StatusButtons.kt @@ -70,22 +70,25 @@ internal class StatusButtons( if(access_info.isNicoru(status.account)) R.attr.ic_nicoru else R.attr.btn_favourite val replies_count = status.replies_count + setButton( btnReply, true, color_normal, R.attr.btn_reply, - when{ + when { replies_count == null || status.visibility == TootStatus.VISIBILITY_DIRECT -> "" - else->when(Pref.ipRepliesCount(activity.pref)){ - Pref.RC_SIMPLE -> when{ - replies_count >= 1 -> "1+" - else -> replies_count.toString() + else -> when(Pref.ipRepliesCount(activity.pref)) { + Pref.RC_SIMPLE -> when { + replies_count >= 2L -> "1+" + replies_count == 1L -> "1" + else -> "" } Pref.RC_ACTUAL -> replies_count.toString() - else->"" + else -> "" } - } + }, + activity.getString(R.string.reply) ) // ブーストボタン @@ -95,33 +98,27 @@ internal class StatusButtons( false, color_accent, R.attr.ic_mail, - "" + "", + activity.getString(R.string.boost) ) - // マストドン2.4.0から非公開トゥートもブーストできるようになった - // TootStatus.VISIBILITY_PRIVATE == status.visibility -> setButton( - // btnBoost, - // false, - // color_accent, - // R.attr.ic_lock, - // "" - // ) + activity.app_state.isBusyBoost(access_info, status) -> setButton( btnBoost, false, color_normal, R.attr.btn_refresh, - "?" + "?", + activity.getString(R.string.boost) ) - else -> { - setButton( - btnBoost, - true, - if(status.reblogged) color_accent else color_normal, - R.attr.btn_boost, - status.reblogs_count?.toString() ?: "" - ) - } + else -> setButton( + btnBoost, + true, + if(status.reblogged) color_accent else color_normal, + R.attr.btn_boost, + status.reblogs_count?.toString() ?: "", + activity.getString(R.string.boost) + ) } when { @@ -130,18 +127,18 @@ internal class StatusButtons( false, color_normal, R.attr.btn_refresh, - "?" + "?", + activity.getString(R.string.favourite) ) - else -> { - setButton( - btnFavourite, - true, - if(status.favourited) color_accent else color_normal, - fav_icon_attr, - status.favourites_count?.toString() ?: "" - ) - } + else -> setButton( + btnFavourite, + true, + if(status.favourited) color_accent else color_normal, + fav_icon_attr, + status.favourites_count?.toString() ?: "", + activity.getString(R.string.favourite) + ) } val account = status.account @@ -163,12 +160,14 @@ internal class StatusButtons( enabled : Boolean, color : Int, icon_attr : Int, - text : String + text : String, + contentDescription : String ) { val d = Styler.getAttributeDrawable(activity, icon_attr).mutate() d.setColorFilter(color, PorterDuff.Mode.SRC_ATOP) b.setCompoundDrawablesRelativeWithIntrinsicBounds(d, null, null, null) b.text = text + b.contentDescription = contentDescription + text b.setTextColor(color) b.isEnabled = enabled } diff --git a/app/src/main/java/jp/juggler/subwaytooter/Styler.kt b/app/src/main/java/jp/juggler/subwaytooter/Styler.kt index aee62be0..962ad900 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/Styler.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/Styler.kt @@ -157,36 +157,43 @@ object Styler { // follow button val color_attr : Int val icon_attr : Int + val contentDescription : String when { relation.blocking -> { icon_attr = R.attr.ic_block color_attr = R.attr.colorImageButton + contentDescription = context.getString(R.string.follow) } relation.muting -> { icon_attr = R.attr.ic_mute color_attr = R.attr.colorImageButton + contentDescription = context.getString(R.string.follow) } relation.getFollowing(who) -> { icon_attr = R.attr.ic_follow_cross color_attr = R.attr.colorImageButtonAccent + contentDescription = context.getString(R.string.unfollow) } relation.getRequested(who) -> { icon_attr = R.attr.ic_follow_wait color_attr = R.attr.colorRegexFilterError + contentDescription = context.getString(R.string.unfollow) } else -> { icon_attr = R.attr.ic_follow_plus color_attr = R.attr.colorImageButton + contentDescription = context.getString(R.string.follow) } } val color = Styler.getAttributeColor(context, color_attr) setIconCustomColor(context, ibFollow, color, icon_attr) + ibFollow.contentDescription = contentDescription } // 色を指定してRippleDrawableを生成する diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Account.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Account.kt index e69536e7..47e9de9c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Account.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Account.kt @@ -39,8 +39,7 @@ object Action_Account { return if(bPseudoAccount || bInputAccessToken) { client.getInstanceInformation() } else { - val client_name = Pref.spClientName(activity) - client.authentication1(client_name) + client.authentication1(Pref.spClientName(activity)) } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_ListMember.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_ListMember.kt index 6fe5ab4d..9b252112 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_ListMember.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_ListMember.kt @@ -46,7 +46,6 @@ object Action_ListMember { var result : TootApiResult? - // TODO: リスト追加時に 422 既に登録されてます みたいなエラーが出る if(bFollow) { val relation : TootRelationShip? if(access_info.isLocalUser(local_who)) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClient.kt b/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClient.kt index e42135f1..581fae8d 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClient.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClient.kt @@ -2,17 +2,15 @@ package jp.juggler.subwaytooter.api import android.content.Context import android.content.SharedPreferences +import jp.juggler.subwaytooter.* import org.json.JSONException import org.json.JSONObject -import jp.juggler.subwaytooter.App1 -import jp.juggler.subwaytooter.Pref import jp.juggler.subwaytooter.table.ClientInfo import jp.juggler.subwaytooter.table.SavedAccount -import jp.juggler.subwaytooter.R +import jp.juggler.subwaytooter.api.entity.ServiceType import jp.juggler.subwaytooter.api.entity.TootInstance -import jp.juggler.subwaytooter.put import jp.juggler.subwaytooter.util.* import okhttp3.* import org.json.JSONArray @@ -65,6 +63,19 @@ class TootApiClient( private const val AUTH_VERSION = 3 private const val REDIRECT_URL = "subwaytooter://oauth/" + const val KEY_IS_MISSKEY = "isMisskey" + const val KEY_API_KEY_MISSKEY = "apiKeyMisskey" + + // APIからsecretを得られないバグがあるので定数を渡す + const val appSecretError = + "Currently Misskey does not allow client registration from API, please tell me notify instance name that you want login via Subway Tooter." + val testAppSecretMap = mapOf( + Pair("misskey.xyz", "NGiWNZFP37WiAee3SGcVe8eSiDyLbbWf") + , Pair("misskey.jp", "GO45N7JgeEWtlNUS4xRcOFY56JMjUTZk") + , Pair("msky.cafe", "lvU12i7CXAB5xiqkABwzyJRzdAqhf0k3") + , Pair("misskey.m544.net", "SLcaqff0Puymh4Fl30JCc09i6uumwJ4t") + ) + private const val NO_INFORMATION = "(no information)" private val reStartJsonArray = Pattern.compile("\\A\\s*\\[") @@ -183,6 +194,19 @@ class TootApiClient( else -> "read+write+follow" } + fun getScopeArrayMisskey(@Suppress("UNUSED_PARAMETER") ti : TootInstance) = + JSONArray().apply { + put("account-read") + put("account-write") + put("note-write") + put("reaction-write") + put("following-write") + put("drive-read") + put("drive-write") + put("notification-read") + put("notification-write") + } + } @Suppress("unused") @@ -440,42 +464,331 @@ class TootApiClient( } } + ////////////////////////////////////////////////////////////////////// + // misskey authentication + // 疑似アカウントの追加時に、インスタンスの検証を行う - fun getInstanceInformation() : TootApiResult? { + private fun getInstanceInformationMisskey() : TootApiResult? { + val result = TootApiResult.makeWithCaption(instance) + if(result.error != null) return result + if(sendRequest(result) { + JSONObject().apply { + put("dummy", 1) + } + .toPostRequestBuilder() + .url("https://$instance/api/meta") + .build() + }) { + parseJson(result) ?: return null + result.jsonObject?.put(KEY_IS_MISSKEY, true) + } + return result + } + + // インスタンス情報を取得する + private fun getInstanceInformation2Misskey() : TootApiResult? { + val r = getInstanceInformationMisskey() + if(r != null) { + val json = r.jsonObject + if(json != null) { + val parser = TootParser( + context, + object : LinkHelper { + override val host : String? + get() = instance + }, + serviceType = ServiceType.MISSKEY + ) + val ti = parser.instance(json) + if(ti != null) { + r.data = ti + } else { + r.setError("can't parse data in /api/meta") + } + } + } + return r + } + + private fun getAppInfoMisskey(appId : String?) : TootApiResult? { + appId ?: return TootApiResult("missing app id") + val result = TootApiResult.makeWithCaption(instance) + if(result.error != null) return result + if(sendRequest(result) { + JSONObject().apply { + put("appId", appId) + } + .toPostRequestBuilder() + .url("https://$instance/api/app/show") + .build() + }) { + parseJson(result) ?: return null + result.jsonObject?.put(KEY_IS_MISSKEY, true) + } + return result + } + + private fun prepareBrowserUrlMisskey(@Suppress("UNUSED_PARAMETER") clientInfo : JSONObject) : String? { + + val result = TootApiResult.makeWithCaption(instance) + + if(result.error != null) { + showToast(context, false, result.error) + return null + } + + val appSecret = testAppSecretMap[instance?.toLowerCase()] + if(appSecret == null) { + showToast(context, true, appSecretError) + return null + } + + if(! sendRequest(result) { + JSONObject().apply { + put("appSecret", appSecret) + } + .toPostRequestBuilder() + .url("https://$instance/api/auth/session/generate") + .build() + } + ) { + val error = result.error + if(error != null) { + showToast(context, false, error) + return null + } + return null + } + + parseJson(result) ?: return null + + val jsonObject = result.jsonObject + if(jsonObject == null) { + showToast(context, false, result.error) + return null + } + // {"token":"0ba88e2d-4b7d-4599-8d90-dc341a005637","url":"https://misskey.xyz/auth/0ba88e2d-4b7d-4599-8d90-dc341a005637"} + + // ブラウザで開くURL + val url = jsonObject.parseString("url") + if(url?.isEmpty() != false) { + showToast(context, false, "missing 'url' in auth session response.") + return null + } + + val e = PrefDevice.prefDevice(context) + .edit() + .putString(PrefDevice.LAST_AUTH_INSTANCE, instance) + .putString(PrefDevice.LAST_AUTH_SECRET, appSecret) + + val account = this.account + if(account != null) { + e.putLong(PrefDevice.LAST_AUTH_DB_ID, account.db_id) + } else { + e.remove(PrefDevice.LAST_AUTH_DB_ID) + } + + e.apply() + + return url + } + + private fun registerClientMisskey( + scope_array : JSONArray, + client_name : String + ) : TootApiResult? { + val result = TootApiResult.makeWithCaption(instance) + if(result.error != null) return result + if(sendRequest(result) { + JSONObject().apply { + put("nameId", "SubwayTooter") + put("name", client_name) + put("description", "Android app for federated SNS") + put("callbackUrl", "subwaytooter://misskey/auth_callback") + put("permission", scope_array) + } + .toPostRequestBuilder() + .url("https://$instance/api/app/create") + .build() + }) { + parseJson(result) ?: return null + } + return result + } + + private fun authentication1Misskey(clientNameArg : String, ti : TootInstance) : TootApiResult? { + val result = TootApiResult.makeWithCaption(this.instance) + if(result.error != null) return result + val instance = result.caption // same to instance + + // クライアントIDがアプリ上に保存されているか? + val client_name = if(clientNameArg.isNotEmpty()) clientNameArg else DEFAULT_CLIENT_NAME + val client_info = ClientInfo.load(instance, client_name) + + // スコープ一覧を取得する + val scope_array = getScopeArrayMisskey(ti) + + if(client_info != null && client_info.optBoolean(KEY_IS_MISSKEY)) { + val r2 = getAppInfoMisskey(client_info.parseString("id")) + val tmpClientInfo = r2?.jsonObject + // tmpClientInfo はsecretを含まないので保存してはいけない + if(tmpClientInfo != null // アプリが登録済みで + && client_name == tmpClientInfo.parseString("name") // クライアント名が一致してて + && tmpClientInfo.optJSONArray("permission")?.length() == scope_array.length() // パーミッションが同じ + ) { + // クライアント情報を再利用する + result.data = prepareBrowserUrlMisskey(client_info) + return result + } else { + // XXX appSecretを使ってクライアント情報を削除できるようにするべきだが、該当するAPIが存在しない + } + } + + val r2 = registerClientMisskey(scope_array, client_name) + val jsonObject = r2?.jsonObject ?: return r2 + + // { + // "createdAt": "2018-08-19T00:43:10.105Z", + // "userId": null, + // "name": "Via芸", + // "nameId": "test1", + // "description": "test1", + // "permission": [ + // "account-read", + // "account-write", + // "note-write", + // "reaction-write", + // "following-write", + // "drive-read", + // "drive-write", + // "notification-read", + // "notification-write" + // ], + // "callbackUrl": "test1://test1/auth_callback", + // "id": "5b78bd1ea0db0527f25815c3", + // "iconUrl": "https://misskey.xyz/files/app-default.jpg" + // } + + // 2018/8/19現在、/api/app/create のレスポンスにsecretが含まれないので認証に使えない + // https://github.com/syuilo/misskey/issues/2343 + + jsonObject.put(KEY_IS_MISSKEY, true) + jsonObject.put(KEY_AUTH_VERSION, AUTH_VERSION) + ClientInfo.save(instance, client_name, jsonObject.toString()) + result.data = prepareBrowserUrlMisskey(jsonObject) + + return result + } + + // oAuth2認証の続きを行う + fun authentication2Misskey(clientNameArg : String, token : String) : TootApiResult? { + val result = TootApiResult.makeWithCaption(instance) + if(result.error != null) return result + val instance = result.caption // same to instance + val client_name = if(clientNameArg.isNotEmpty()) clientNameArg else DEFAULT_CLIENT_NAME + + @Suppress("UNUSED_VARIABLE") + val client_info = ClientInfo.load(instance, client_name) + ?: return result.setError("missing client id") + + // XXX: client_info中にsecretがあればそれを使う + val appSecret = testAppSecretMap[instance.toLowerCase()] + ?: return result.setError(appSecretError) + + if(! sendRequest(result) { + JSONObject().apply { + put("appSecret", appSecret) + put("token", token) + } + .toPostRequestBuilder() + .url("https://$instance/api/auth/session/userkey") + .build() + } + ) { + return result + } + + parseJson(result) ?: return null + + val token_info = result.jsonObject ?: return result + + // {"accessToken":"XSdxQcaCCS5VDRNigfDDj9xNDBpPlD8K","user":{…}} + + val access_token = token_info.parseString("accessToken") + if(access_token?.isEmpty() != false) { + return result.setError("missing accessToken in the response.") + } + + val user = token_info.optJSONObject("user") + ?: result.setError("missing user in the response.") + token_info.remove("user") + + val apiKey = "$access_token$appSecret".encodeUTF8().digestSHA256().encodeHexLower() + + // ユーザ情報を読めたならtokenInfoを保存する + token_info.put(KEY_IS_MISSKEY, true) + token_info.put(KEY_AUTH_VERSION, AUTH_VERSION) + token_info.put(KEY_API_KEY_MISSKEY, apiKey) + + // tokenInfoとユーザ情報の入ったresultを返す + result.tokenInfo = token_info + result.data = user + return result + } + + ////////////////////////////////////////////////////////////////////// + + //////////////////////////////////////////////////////////////// + + // 疑似アカウントの追加時に、インスタンスの検証を行う + private fun getInstanceInformationMastodon() : TootApiResult? { val result = TootApiResult.makeWithCaption(instance) if(result.error != null) return result if(sendRequest(result) { Request.Builder().url("https://$instance/api/v1/instance").build() } - && parseJson(result) != null - && result.jsonObject != null ) { - // インスタンス情報のjsonを読めたらマストドンのインスタンス - return result - } - - // misskeyか試してみる - val r2 = TootApiResult.makeWithCaption(instance) - if(sendRequest(r2) { - Request.Builder().post(RequestBody.create(MEDIA_TYPE_JSON, JSONObject().apply { - put("dummy", 1) - }.toString())) - .url("https://$instance/api/notes/local-timeline").build() - } - ) { - if(parseJson(r2) != null && r2.jsonArray != null) { - r2.data = JSONObject().apply { - put("isMisskey", true) - } - return r2 - } + parseJson(result) ?: return null } // misskeyの事は忘れて本来のエラー情報を返す return result } + private fun getInstanceInformation2Mastodon() : TootApiResult? { + val r = getInstanceInformationMastodon() + if(r != null) { + val json = r.jsonObject + if(json != null) { + val parser = TootParser(context, object : LinkHelper { + override val host : String? + get() = instance + }) + val ti = parser.instance(json) + if(ti != null) { + r.data = ti + } else { + r.setError("can't parse data in /api/v1/instance") + } + } + } + return r + } + + // 疑似アカウントの追加時に、インスタンスの検証を行う + fun getInstanceInformation() : TootApiResult? { + // マストドンのインスタンス情報を読めたら、それはマストドンのインスタンス + val r1 = getInstanceInformationMastodon() ?: return null // null means cancelled. + if(r1.jsonObject != null) return r1 + + // misskeyのインスタンス情報を読めたら、それはmisskeyのインスタンス + val r2 = getInstanceInformationMisskey() ?: return null // null means cancelled. + if(r2.jsonObject != null) return r2 + + return r1 // 通信エラーの表示ならr1でもr2でも構わないはず + } + // インスタンス情報を取得する internal fun getInstanceInformation2() : TootApiResult? { val r = getInstanceInformation() @@ -498,7 +811,7 @@ class TootApiClient( } // クライアントをタンスに登録 - internal fun registerClient(scope_string : String, clientName : String) : TootApiResult? { + private fun registerClient(scope_string : String, clientName : String) : TootApiResult? { val result = TootApiResult.makeWithCaption(this.instance) if(result.error != null) return result val instance = result.caption // same to instance @@ -577,8 +890,8 @@ class TootApiClient( return parseJson(result) } - // // client_credentialを無効にする - internal fun revokeClientCredential( + // client_credentialを無効にする + private fun revokeClientCredential( client_info : JSONObject, client_credential : String ) : TootApiResult? { @@ -619,21 +932,19 @@ class TootApiClient( + "&redirect_uri=" + REDIRECT_URL.encodePercent() + "&scope=$scope_string" + "&scopes=$scope_string" - + "&state=" + (if(account != null) "db:" + account.db_id else "host:" + instance) + + "&state=" + (if(account != null) "db:${account.db_id}" else "host:$instance") + "&grant_type=authorization_code" + "&approval_prompt=force" // +"&access_type=offline" ) } - // クライアントを登録してブラウザで開くURLを生成する - fun authentication1(clientNameArg : String) : TootApiResult? { - - // インスタンス情報の取得 - val ri = getInstanceInformation2() - val ti = ri?.data as? TootInstance ?: return ri - val scope_string = getScopeString(ti) + private fun authentication1Mastodon( + clientNameArg : String, + ti : TootInstance + ) : TootApiResult? { + // 前準備 val result = TootApiResult.makeWithCaption(this.instance) if(result.error != null) return result val instance = result.caption // same to instance @@ -641,7 +952,12 @@ class TootApiClient( // クライアントIDがアプリ上に保存されているか? val client_name = if(clientNameArg.isNotEmpty()) clientNameArg else DEFAULT_CLIENT_NAME val client_info = ClientInfo.load(instance, client_name) - if(client_info != null) { + + // スコープ一覧を取得する + + val scope_string = getScopeString(ti) + + if(client_info != null && ! client_info.optBoolean(KEY_IS_MISSKEY)) { var client_credential = client_info.parseString(KEY_CLIENT_CREDENTIAL) val old_scope = client_info.parseString(KEY_CLIENT_SCOPE) @@ -672,7 +988,7 @@ class TootApiClient( // client credential をタンスから消去する revokeClientCredential(client_info, client_credential) - // FIXME クライアントアプリ情報そのものはまだサーバに残っているが、明示的に消す方法は現状存在しない + // XXX クライアントアプリ情報そのものはまだサーバに残っているが、明示的に消す方法は現状存在しない } else { // クライアント情報を再利用する result.data = prepareBrowserUrl(scope_string, client_info) @@ -694,6 +1010,26 @@ class TootApiClient( return result } + // クライアントを登録してブラウザで開くURLを生成する + fun authentication1(clientNameArg : String) : TootApiResult? { + + // マストドンのインスタンス情報 + var ri = getInstanceInformation2Mastodon() + var ti = ri?.data as? TootInstance + if(ti != null && (ri?.response?.code() ?: 0) in 200 until 300) { + return authentication1Mastodon(clientNameArg, ti) + } + + // misskeyのインスタンス情報 + ri = getInstanceInformation2Misskey() + ti = ri?.data as? TootInstance + if(ti != null && (ri?.response?.code() ?: 0) in 200 until 300) { + return authentication1Misskey(clientNameArg, ti) + } + + return ri + } + // oAuth2認証の続きを行う fun authentication2(clientNameArg : String, code : String) : TootApiResult? { val result = TootApiResult.makeWithCaption(instance) @@ -819,7 +1155,7 @@ class TootApiClient( .append("?apikey=").append(mspApiKey.encodePercent()) .append("&utoken=").append(user_token.encodePercent()) .append("&q=").append(query.encodePercent()) - .append("&max=").append(max_id?.encodePercent() ?:"") + .append("&max=").append(max_id?.encodePercent() ?: "") Request.Builder().url(url.toString()).build() }) return result @@ -882,14 +1218,14 @@ class TootApiClient( return result } - fun requestJson(req : Request) : TootApiResult? { - val result = TootApiResult.makeWithCaption(req.url().host()) - if(result.error != null) return result - if(sendRequest(result, progressPath = null) { req }) { - parseJson(result) - } - return result - } +// fun requestJson(req : Request) : TootApiResult? { +// val result = TootApiResult.makeWithCaption(req.url().host()) +// if(result.error != null) return result +// if(sendRequest(result, progressPath = null) { req }) { +// parseJson(result) +// } +// return result +// } // 疑似アカウントでステータスURLからステータスIDを取得するためにHTMLを取得する fun getHttp(url : String) : TootApiResult? { diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/EntityId.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/EntityId.kt new file mode 100644 index 00000000..1262c4c6 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/EntityId.kt @@ -0,0 +1,114 @@ +package jp.juggler.subwaytooter.api.entity + +import android.content.Intent +import android.os.Bundle +import org.json.JSONObject + +abstract class EntityId : Comparable { + + abstract fun toLong() : Long + abstract fun putMisskeyUntil(dst : JSONObject) : JSONObject + abstract fun putMisskeySince(dst : JSONObject) : JSONObject + + + companion object { + + fun from(x : Long) = EntityIdLong(x) + + fun mayNull(x : Long?) = when(x) { + null -> null + else -> EntityIdLong(x) + } + + fun from(x : String) = EntityIdString(x) + + fun mayNull(x : String?) = when(x) { + null -> null + else -> EntityIdString(x) + } + + fun String.decode():EntityId?{ + if(this.isEmpty()) return null + if(this[0]=='L') return from(this.substring(1).toLong()) + if(this[0]=='S') return from(this.substring(1)) + return null + } + + fun from(intent: Intent, key:String)= + intent.getStringExtra(key)?.decode() + + fun from(bundle: Bundle, key:String)= + bundle.getString(key)?.decode() + + fun from(data : JSONObject, key : String): EntityId?{ + val o = data.opt(key) + if(o is Long) return EntityIdLong(o) + return (o as? String)?.decode() + } + } + + private fun encode():String{ + val prefix = when(this){ + is EntityIdLong ->'L' + is EntityIdString->'S' + else -> error("unknown type") + } + return "$prefix$this" + } + + fun putTo(data : Intent, key : String) :Intent = data.putExtra( key,encode()) + + fun putTo(bundle:Bundle, key : String) = bundle.putString( key,encode()) + + fun putTo(data:JSONObject, key : String):JSONObject = data.put( key,encode()) + +} + +class EntityIdLong(val x : Long) : EntityId() { + + override fun compareTo(other : EntityId) = when(other) { + this -> 0 + is EntityIdLong -> x.compareTo(other.x) + else -> error("EntityIdLong: compare with ${other::javaClass.name}") + } + + override fun equals(other : Any?) =when(other) { + is EntityIdLong -> x == other.x + is EntityIdString -> x.toString() == other.x + else -> false + } + + override fun hashCode() = (x xor x.ushr(32)).toInt() + + override fun toString() = x.toString() + + override fun toLong() = x + + override fun putMisskeyUntil(dst : JSONObject) : JSONObject = dst.put("untilDate", x) + override fun putMisskeySince(dst : JSONObject) : JSONObject = dst.put("sinceDate", x) + +} + +class EntityIdString(val x : String) : EntityId() { + + override fun compareTo(other : EntityId) = when(other) { + is EntityIdString -> x.compareTo(other.x) + else -> error("EntityIdLong: compare with ${other::javaClass.name}") + } + + override fun equals(other : Any?) =when(other) { + is EntityIdString -> x == other.x + is EntityIdLong -> x == other.x.toString() + else -> false + } + + override fun hashCode() = x.hashCode() + + override fun toString() = x + + override fun toLong() = TootStatus.INVALID_ID // error("can't convert string ID to long") + + override fun putMisskeyUntil(dst : JSONObject) : JSONObject = dst.put("untilId", x) + override fun putMisskeySince(dst : JSONObject) : JSONObject = dst.put("sinceId", x) + +} 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 f8e26b48..073c1b9e 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 @@ -31,7 +31,8 @@ class TootInstance(parser : TootParser, src : JSONObject) { // A description for the instance val description : String? - // An email address which can be used to contact the instance administrator + // An email address which can be used to contact the instance administrator + // misskeyの場合はURLらしい val email : String? val version : String? @@ -57,40 +58,59 @@ class TootInstance(parser : TootParser, src : JSONObject) { enum class InstanceType { Mastodon, - Pleroma + Pleroma, + Misskey } - private val instanceType : InstanceType + val instanceType : InstanceType // XXX: urls をパースしてない。使ってないから… init { - this.uri = src.parseString("uri") - this.title = src.parseString("title") - this.description = src.parseString("description") - this.email = src.parseString("email") - this.version = src.parseString("version") - this.decoded_version = VersionString(version) - this.stats = parseItem(::Stats, src.optJSONObject("stats")) - this.thumbnail = src.parseString("thumbnail") - - this.max_toot_chars = src.parseInt("max_toot_chars") - - this.instanceType = when { - rePleroma.matcher(version ?: "").find() -> InstanceType.Pleroma - else -> InstanceType.Mastodon - } - - languages = src.optJSONArray("languages")?.toStringArrayList() - - val parser2 = TootParser( - parser.context, - object : LinkHelper { - override val host : String - get() = uri ?: "?" + if(parser.serviceType == ServiceType.MISSKEY){ + + this.uri = parser.linkHelper.host + this.title = parser.linkHelper.host + this.description = "(Misskey instance)" + this.email = src.optJSONObject("maintainer")?.parseString("url") + this.version = src.parseString("version") + this.decoded_version = VersionString(version) + this.stats = null + this.thumbnail = null + this.max_toot_chars = 1000 + this.instanceType = InstanceType.Misskey + this.languages = ArrayList().also{ it.add("?")} + this.contact_account = null + + }else { + this.uri = src.parseString("uri") + this.title = src.parseString("title") + this.description = src.parseString("description") + this.email = src.parseString("email") + this.version = src.parseString("version") + this.decoded_version = VersionString(version) + this.stats = parseItem(::Stats, src.optJSONObject("stats")) + this.thumbnail = src.parseString("thumbnail") + + this.max_toot_chars = src.parseInt("max_toot_chars") + + this.instanceType = when { + rePleroma.matcher(version ?: "").find() -> InstanceType.Pleroma + else -> InstanceType.Mastodon } - ) - contact_account = parseItem(::TootAccount, parser2, src.optJSONObject("contact_account")) + + languages = src.optJSONArray("languages")?.toStringArrayList() + + val parser2 = TootParser( + parser.context, + object : LinkHelper { + override val host : String + get() = uri ?: "?" + } + ) + contact_account = + parseItem(::TootAccount, parser2, src.optJSONObject("contact_account")) + } } class Stats(src : JSONObject) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/dialog/LoginForm.kt b/app/src/main/java/jp/juggler/subwaytooter/dialog/LoginForm.kt index 2868b6af..5e0a63c7 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/dialog/LoginForm.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/dialog/LoginForm.kt @@ -100,7 +100,7 @@ object LoginForm { activity, R.layout.lv_spinner_dropdown, ArrayList() ) { - internal val nameFilter : Filter = object : Filter() { + val nameFilter : Filter = object : Filter() { override fun convertResultToString(value : Any) : CharSequence { return value as String } diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/ContentWarning.kt b/app/src/main/java/jp/juggler/subwaytooter/table/ContentWarning.kt index 9610002f..083f3b6e 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/ContentWarning.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/ContentWarning.kt @@ -4,6 +4,7 @@ import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import jp.juggler.subwaytooter.App1 +import jp.juggler.subwaytooter.api.entity.EntityIdString import jp.juggler.subwaytooter.api.entity.TootStatus import jp.juggler.subwaytooter.util.LogCategory @@ -42,6 +43,9 @@ object ContentWarning :TableCompanion{ } fun isShown(status : TootStatus, default_value : Boolean) : Boolean { + + if( status.idAccessOrOriginal is EntityIdString) return ContentWarningMisskey.isShown(status,default_value) + try { App1.database.query( table, @@ -69,6 +73,10 @@ object ContentWarning :TableCompanion{ } fun save(status : TootStatus, is_shown : Boolean) { + + if( status.idAccessOrOriginal is EntityIdString) return ContentWarningMisskey.save(status,is_shown) + + try { val now = System.currentTimeMillis() diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/ContentWarningMisskey.kt b/app/src/main/java/jp/juggler/subwaytooter/table/ContentWarningMisskey.kt new file mode 100644 index 00000000..e31f8c1f --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/table/ContentWarningMisskey.kt @@ -0,0 +1,91 @@ +package jp.juggler.subwaytooter.table + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase + +import jp.juggler.subwaytooter.App1 +import jp.juggler.subwaytooter.api.entity.TootStatus +import jp.juggler.subwaytooter.util.LogCategory + +object ContentWarningMisskey :TableCompanion{ + private val log = LogCategory("ContentWarning") + + private const val table = "content_warning" + private const val COL_HOST = "h" + private const val COL_STATUS_ID = "si" + private const val COL_SHOWN = "sh" + private const val COL_TIME_SAVE = "time_save" + + private val projection_shown = arrayOf(COL_SHOWN) + + override fun onDBCreate(db : SQLiteDatabase) { + log.d("onDBCreate!") + db.execSQL( + "create table if not exists $table" + + "(_id INTEGER PRIMARY KEY" + + ",$COL_HOST text not null" + + ",$COL_STATUS_ID text not null" + + ",$COL_SHOWN integer not null" + + ",$COL_TIME_SAVE integer default 0" + + ")" + ) + db.execSQL( + "create unique index if not exists ${table}_status_id on $table($COL_HOST,$COL_STATUS_ID)" + ) + } + + override fun onDBUpgrade(db : SQLiteDatabase, oldVersion : Int, newVersion : Int) { + if(oldVersion < 29 && newVersion >= 29 ) { + db.execSQL("drop table if exists $table") + onDBCreate(db) + } + } + + fun isShown(status : TootStatus, default_value : Boolean) : Boolean { + try { + App1.database.query( + table, + projection_shown, + "h=? and si=?", + arrayOf( + status.hostAccessOrOriginal, + status.idAccessOrOriginal.toString() + ), + null, + null, + null + ).use { cursor -> + if(cursor.moveToFirst()) { + val iv = cursor.getInt(cursor.getColumnIndex(COL_SHOWN)) + return 0 != iv + } + + } + } catch(ex : Throwable) { + log.e(ex, "load failed.") + } + + return default_value + } + + fun save(status : TootStatus, is_shown : Boolean) { + try { + val now = System.currentTimeMillis() + + val cv = ContentValues() + cv.put(COL_HOST, status.hostAccessOrOriginal) + cv.put(COL_STATUS_ID, status.idAccessOrOriginal.toString()) + cv.put(COL_SHOWN, is_shown.b2i()) + cv.put(COL_TIME_SAVE, now) + App1.database.replace(table, null, cv) + + // 古いデータを掃除する + val expire = now - 86400000L * 365 + App1.database.delete(table, "$COL_TIME_SAVE= 29) { + db.execSQL("drop table if exists $table") + onDBCreate(db) + } + } + + fun isShown(status : TootStatus, default_value : Boolean) : Boolean { + try { + App1.database.query( + table, + projection_shown, + "h=? and si=?", + arrayOf( + status.hostAccessOrOriginal, + status.idAccessOrOriginal.toString() + ), + null, + null, + null + ).use { cursor -> + if(cursor.moveToFirst()) { + return 0 != cursor.getInt(cursor.getColumnIndex(COL_SHOWN)) + } + + } + } catch(ex : Throwable) { + log.e(ex, "load failed.") + } + + return default_value + } + + fun save(status : TootStatus, is_shown : Boolean) { + try { + val now = System.currentTimeMillis() + + val cv = ContentValues() + cv.put(COL_HOST, status.hostAccessOrOriginal) + cv.put(COL_STATUS_ID, status.idAccessOrOriginal.toString()) + cv.put(COL_SHOWN, is_shown.b2i()) + cv.put(COL_TIME_SAVE, now) + App1.database.replace(table, null, cv) + + // 古いデータを掃除する + val expire = now - 86400000L * 365 + App1.database.delete(table, "$COL_TIME_SAVE