特定のMisskeyタンスにログインできる

This commit is contained in:
tateisu 2018-08-19 13:19:33 +09:00
parent 75005c3870
commit 7517f58a3c
20 changed files with 931 additions and 186 deletions

View File

@ -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.

View File

@ -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

View File

@ -77,6 +77,19 @@
/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:host="*"
android:pathPattern=".*"
android:scheme="misskeyclientproto"
/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>

View File

@ -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

View File

@ -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,

View File

@ -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
}

View File

@ -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"
}

View File

@ -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
}

View File

@ -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を生成する

View File

@ -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))
}
}

View File

@ -46,7 +46,6 @@ object Action_ListMember {
var result : TootApiResult?
// TODO: リスト追加時に 422 既に登録されてます みたいなエラーが出る
if(bFollow) {
val relation : TootRelationShip?
if(access_info.isLocalUser(local_who)) {

View File

@ -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? {

View File

@ -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<EntityId> {
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)
}

View File

@ -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<String>().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) {

View File

@ -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
}

View File

@ -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()

View File

@ -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<?", arrayOf(expire.toString()))
} catch(ex : Throwable) {
log.e(ex, "save failed.")
}
}
}

View File

@ -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,7 @@ object MediaShown:TableCompanion {
}
fun isShown(status : TootStatus, default_value : Boolean) : Boolean {
if( status.idAccessOrOriginal is EntityIdString) return MediaShownMisskey.isShown(status,default_value)
try {
App1.database.query(
table,
@ -68,6 +70,7 @@ object MediaShown:TableCompanion {
}
fun save(status : TootStatus, is_shown : Boolean) {
if( status.idAccessOrOriginal is EntityIdString) return MediaShownMisskey.save(status,is_shown)
try {
val now = System.currentTimeMillis()

View File

@ -0,0 +1,90 @@
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 MediaShownMisskey:TableCompanion {
private val log = LogCategory("MediaShown")
private const val table = "media_shown"
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()) {
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<?", arrayOf(expire.toString()))
} catch(ex : Throwable) {
log.e(ex, "save failed.")
}
}
}

View File

@ -4,12 +4,10 @@ import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.res.Resources
import android.graphics.Rect
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.support.v7.widget.RecyclerView
import android.text.Spannable
import android.text.SpannableString
import android.text.SpannableStringBuilder
@ -32,7 +30,6 @@ import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.lang.ref.WeakReference
import java.lang.reflect.Field
import java.security.MessageDigest
import java.util.LinkedList
import java.util.Locale
@ -48,7 +45,7 @@ object Utils {
val log = LogCategory("Utils")
val hex =
val hexLower =
charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
/////////////////////////////////////////////
@ -430,11 +427,22 @@ fun String.encodeUTF8() = this.toByteArray(charsetUTF8)
fun ByteArray.decodeUTF8() = this.toString(charsetUTF8)
fun StringBuilder.appendHex2(value : Int) : StringBuilder {
this.append(Utils.hex[(value shr 4) and 15])
this.append(Utils.hex[value and 15])
this.append(Utils.hexLower[(value shr 4) and 15])
this.append(Utils.hexLower[value and 15])
return this
}
fun ByteArray.encodeHexLower() : String {
val size = this.size
val sb = StringBuilder(size *2)
for( i in 0 until size){
val value = this[i].toInt()
sb.append(Utils.hexLower[(value shr 4) and 15])
sb.append(Utils.hexLower[value and 15])
}
return sb.toString()
}
fun String?.optInt() : Int? {
return try {
this?.toInt(10)