1
0
mirror of https://github.com/tateisu/SubwayTooter synced 2025-01-28 01:29:23 +01:00

Mastodon 2.4 のWebPush REST APIに対応

This commit is contained in:
tateisu 2018-05-12 17:17:12 +09:00
parent 3f3279243f
commit 74e434a771
11 changed files with 451 additions and 204 deletions

View File

@ -12,8 +12,8 @@ android {
minSdkVersion 21
targetSdkVersion 27
versionCode 246
versionName "2.4.6"
versionCode 248
versionName "2.4.8"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
// https://stackoverflow.com/questions/47791227/java-lang-illegalstateexception-dex-archives-setting-dex-extension-only-for

View File

@ -101,7 +101,7 @@ class ActAccountSetting
private lateinit var swNSFWOpen : Switch
private lateinit var swDontShowTimeout : Switch
private lateinit var btnOpenBrowser : Button
private lateinit var btnPushTest : Button
private lateinit var btnPushSubscription : Button
private lateinit var cbNotificationMention : CheckBox
private lateinit var cbNotificationBoost : CheckBox
private lateinit var cbNotificationFavourite : CheckBox
@ -273,7 +273,7 @@ class ActAccountSetting
swNSFWOpen = findViewById(R.id.swNSFWOpen)
swDontShowTimeout = findViewById(R.id.swDontShowTimeout)
btnOpenBrowser = findViewById(R.id.btnOpenBrowser)
btnPushTest= findViewById(R.id.btnPushTest)
btnPushSubscription = findViewById(R.id.btnPushSubscription)
cbNotificationMention = findViewById(R.id.cbNotificationMention)
cbNotificationBoost = findViewById(R.id.cbNotificationBoost)
cbNotificationFavourite = findViewById(R.id.cbNotificationFavourite)
@ -318,7 +318,7 @@ class ActAccountSetting
btnFields = findViewById(R.id.btnFields)
btnOpenBrowser.setOnClickListener(this)
btnPushTest.setOnClickListener(this)
btnPushSubscription.setOnClickListener(this)
btnAccessToken.setOnClickListener(this)
btnInputAccessToken.setOnClickListener(this)
btnAccountRemove.setOnClickListener(this)
@ -407,6 +407,7 @@ class ActAccountSetting
btnAccessToken.isEnabled = enabled
btnInputAccessToken.isEnabled = enabled
btnVisibility.isEnabled = enabled
btnPushSubscription.isEnabled = enabled
btnNotificationSoundEdit.isEnabled = Build.VERSION.SDK_INT < 26 && enabled
btnNotificationSoundReset.isEnabled = Build.VERSION.SDK_INT < 26 && enabled
@ -489,8 +490,8 @@ class ActAccountSetting
R.id.btnAccountRemove -> performAccountRemove()
R.id.btnVisibility -> performVisibility()
R.id.btnOpenBrowser -> open_browser("https://" + account.host + "/")
R.id.btnPushTest-> startTest()
R.id.btnPushSubscription -> startTest()
R.id.btnUserCustom -> ActNickname.open(
this,
full_acct,
@ -843,22 +844,22 @@ class ActAccountSetting
else -> fields[i].first
}
)
et.setText( text )
et.setText(text)
et.isEnabled = true
val invalidator = NetworkEmojiInvalidator(et.handler,et)
val invalidator = NetworkEmojiInvalidator(et.handler, et)
invalidator.register(text)
}
listEtFieldValue.forEachIndexed { i, et ->
val text =decodeOptions.decodeEmoji(
val text = decodeOptions.decodeEmoji(
when {
i >= fields.size -> ""
else -> fields[i].second
}
)
et.setText( text )
et.setText(text)
et.isEnabled = true
val invalidator = NetworkEmojiInvalidator(et.handler,et)
val invalidator = NetworkEmojiInvalidator(et.handler, et)
invalidator.register(text)
}
@ -866,7 +867,7 @@ class ActAccountSetting
val fields = src.fields
listEtFieldName.forEachIndexed { i, et ->
val text = decodeOptionsNoCustomEmoji.decodeEmoji(
val text = decodeOptionsNoCustomEmoji.decodeEmoji(
when {
fields == null || i >= fields.size -> ""
else -> fields[i].first
@ -874,12 +875,12 @@ class ActAccountSetting
)
et.setText(text)
et.isEnabled = true
val invalidator = NetworkEmojiInvalidator(et.handler,et)
val invalidator = NetworkEmojiInvalidator(et.handler, et)
invalidator.register(text)
}
listEtFieldValue.forEachIndexed { i, et ->
val text = decodeOptions.decodeHTML(
val text = decodeOptions.decodeHTML(
when {
fields == null || i >= fields.size -> ""
else -> fields[i].second
@ -887,7 +888,7 @@ class ActAccountSetting
)
et.text = text
et.isEnabled = true
val invalidator = NetworkEmojiInvalidator(et.handler,et)
val invalidator = NetworkEmojiInvalidator(et.handler, et)
invalidator.register(text)
}
}
@ -1328,73 +1329,25 @@ class ActAccountSetting
@SuppressLint("StaticFieldLeak")
private fun startTest() {
TootTaskRunner(this).run(account,object:TootTask{
val sb = StringBuilder()
TootTaskRunner(this).run(account, object : TootTask {
val wps = WebPushSubscription(this@ActAccountSetting,verbose = true)
private fun addLog(s:String) {
if(sb.isNotEmpty()) sb.append('\n')
sb.append(s)
override fun background(client : TootApiClient) : TootApiResult? {
return wps.updateSubscription(client,account)
}
override fun background(client : TootApiClient) : TootApiResult? { // TODO
// インスタンスバージョンの確認
var r = client.getInstanceInformation2()
val ti = r?.data as? TootInstance ?: return r
if(!ti.isEnoughVersion(TootInstance.VERSION_2_4)){
addLog("Too old instance version ${ti.version} that does not support Push API.")
return r
}
// プッシュ通知の登録
var json :JSONObject? = JSONObject().also{
it.put("subscription",JSONObject().also {
it.put("endpoint","${PollingWorker.APP_SERVER}/webpushcallback")
it.put("keys",JSONObject().also {
it.put(
"p256dh",
"BEm_a0bdPDhf0SOsrnB2-ategf1hHoCnpXgQsFj5JCkcoMrMt2WHoPfEYOYPzOIs9mZE8ZUaD7VA5vouy0kEkr8="
)
it.put("auth", "eH_C8rq2raXqlcBVDa1gLg==")
})
})
it.put("data","<<DATA>>")
}
var req = Request.Builder().post(
RequestBody.create(TootApiClient.MEDIA_TYPE_JSON,json.toString())
)
r = client.request("/api/v1/push/subscription",req)
var response = r?.response
if( response != null ){
when(response.code()){
404 ->{
addLog("this instance has no API endpoint 'POST /api/v1/push/subscription'. instance version is ${ti.version}")
return r
}
403 ->{
addLog("Your access token does not contains push scope. updating access token is recommended.")
return r
}
}
addLog( "${response.request()}" )
addLog("${response.code()} ${response.message()}")
json = r?.jsonObject
if(json != null) {
addLog(json.toString())
}
}
return r
}
override fun handleResult(result : TootApiResult?) {
val e = result?.error
if(e != null) addLog(e)
AlertDialog.Builder(this@ActAccountSetting)
.setMessage(sb)
.setPositiveButton(R.string.close,null)
.show()
result ?: return
val log = wps.log
if( log.isNotEmpty() ){
AlertDialog.Builder(this@ActAccountSetting)
.setMessage(log)
.setPositiveButton(R.string.close, null)
.show()
}
}
})
}
}

View File

@ -23,9 +23,9 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
if(data != null) {
for((key, value) in data) {
log.d("onMessageReceived: %s=%s", key, value)
if("notification_tag" == key) {
tag = value
when(key){
"notification_tag" -> tag = value
"acct" -> tag= "acct<>$value"
}
}
}

View File

@ -112,6 +112,92 @@ class PollingWorker private constructor(c : Context) {
return s
}
fun getDeviceId(context : Context) : String? {
val prefDevice = PrefDevice.prefDevice(context)
var device_token = prefDevice.getString(PrefDevice.KEY_DEVICE_TOKEN, null)
if(device_token?.isNotEmpty() == true) return device_token
try {
device_token = FirebaseInstanceId.getInstance().token
if(device_token?.isNotEmpty() == true) {
prefDevice.edit().putString(PrefDevice.KEY_DEVICE_TOKEN, device_token)
.apply()
return device_token
}
log.e("getDeviceId: missing device token.")
return null
} catch(ex : Throwable) {
log.e("getDeviceId: could not get device token.")
log.trace(ex)
return null
}
}
// インストールIDを生成する前に、各データの通知登録キャッシュをクリアする
// トークンがまだ生成されていない場合、このメソッドは null を返します。
fun prepareInstallId(
context : Context,
job : JobItem? = null
) : String? {
val prefDevice = PrefDevice.prefDevice(context)
var sv = prefDevice.getString(PrefDevice.KEY_INSTALL_ID, null)
if(sv?.isNotEmpty() == true) return sv
SavedAccount.clearRegistrationCache()
try {
var device_token = prefDevice.getString(PrefDevice.KEY_DEVICE_TOKEN, null)
if(device_token == null || device_token.isEmpty()) {
try {
device_token = FirebaseInstanceId.getInstance().token
if(device_token == null || device_token.isEmpty()) {
log.e("getInstallId: missing device token.")
return null
} else {
prefDevice.edit().putString(PrefDevice.KEY_DEVICE_TOKEN, device_token)
.apply()
}
} catch(ex : Throwable) {
log.e("getInstallId: could not get device token.")
log.trace(ex)
return null
}
}
val request = Request.Builder()
.url("$APP_SERVER/counter")
.build()
val call = App1.ok_http_client.newCall(request)
if(job != null) {
job.current_call = call
}
val response = call.execute()
val body = response.body()?.string()
if(! response.isSuccessful || body?.isEmpty() != false) {
log.e(
TootApiClient.formatResponse(
response,
"getInstallId: get /counter failed."
)
)
return null
}
sv = (device_token + UUID.randomUUID() + body).digestSHA256()
prefDevice.edit().putString(PrefDevice.KEY_INSTALL_ID, sv).apply()
return sv
} catch(ex : Throwable) {
log.trace(ex)
}
return null
}
//////////////////////////////////////////////////////////////////////
// タスクの管理
@ -364,7 +450,7 @@ class PollingWorker private constructor(c : Context) {
worker.start()
}
internal inner class Worker : WorkerBase() {
inner class Worker : WorkerBase() {
val bThreadCancelled = AtomicBoolean(false)
@ -551,7 +637,7 @@ class PollingWorker private constructor(c : Context) {
internal class JobCancelledException : RuntimeException("job is cancelled.")
internal inner class JobItem {
inner class JobItem {
val jobId : Int
private val refJobService : WeakReference<JobService>?
private val jobParams : JobParameters?
@ -781,10 +867,21 @@ class PollingWorker private constructor(c : Context) {
var bDone = false
val tag = taskData.parseString(EXTRA_TAG)
if(tag != null) {
for(sa in SavedAccount.loadByTag(context, tag)) {
NotificationTracking.resetLastLoad(sa.db_id)
process_db_id = sa.db_id
bDone = true
if(tag.startsWith("acct<>")) {
val acct = tag.substring(6)
val sa = SavedAccount.loadAccountByAcct(context, acct)
if(sa != null) {
NotificationTracking.resetLastLoad(sa.db_id)
process_db_id = sa.db_id
bDone = true
}
}
if(! bDone) {
for(sa in SavedAccount.loadByTag(context, tag)) {
NotificationTracking.resetLastLoad(sa.db_id)
process_db_id = sa.db_id
bDone = true
}
}
}
if(! bDone) {
@ -833,7 +930,7 @@ class PollingWorker private constructor(c : Context) {
// インストールID生成時にSavedAccountテーブルを操作することがあるので
// アカウントリストの取得より先に行う
if(job.install_id == null) {
job.install_id = prepareInstallId()
job.install_id = prepareInstallId(context, job)
}
// アカウント別に処理スレッドを作る
@ -875,66 +972,6 @@ class PollingWorker private constructor(c : Context) {
}
}
// インストールIDを生成する前に、各データの通知登録キャッシュをクリアする
// トークンがまだ生成されていない場合、このメソッドは null を返します。
private fun prepareInstallId() : String? {
val prefDevice = PrefDevice.prefDevice(context)
var sv = prefDevice.getString(PrefDevice.KEY_INSTALL_ID, null)
if(sv?.isNotEmpty() == true) return sv
SavedAccount.clearRegistrationCache()
try {
var device_token = prefDevice.getString(PrefDevice.KEY_DEVICE_TOKEN, null)
if(device_token == null || device_token.isEmpty()) {
try {
device_token = FirebaseInstanceId.getInstance().token
if(device_token == null || device_token.isEmpty()) {
log.e("getInstallId: missing device token.")
return null
} else {
prefDevice.edit().putString(PrefDevice.KEY_DEVICE_TOKEN, device_token)
.apply()
}
} catch(ex : Throwable) {
log.e("getInstallId: could not get device token.")
log.trace(ex)
return null
}
}
val request = Request.Builder()
.url("$APP_SERVER/counter")
.build()
val call = App1.ok_http_client.newCall(request)
job.current_call = call
val response = call.execute()
val body = response.body()?.string()
if(! response.isSuccessful || body?.isEmpty() != false) {
log.e(
TootApiClient.formatResponse(
response,
"getInstallId: get /counter failed."
)
)
return null
}
sv = (device_token + UUID.randomUUID() + body).digestSHA256()
prefDevice.edit().putString(PrefDevice.KEY_INSTALL_ID, sv).apply()
return sv
} catch(ex : Throwable) {
log.trace(ex)
}
return null
}
private fun createErrorNotification(error_instance : ArrayList<String>) {
if(error_instance.isEmpty()) {
return
@ -1014,8 +1051,9 @@ class PollingWorker private constructor(c : Context) {
}
}
internal inner class AccountThread(val account : SavedAccount) : Thread(),
CurrentCallCallback {
internal inner class AccountThread(
val account : SavedAccount
) : Thread(), CurrentCallCallback {
private var current_call : Call? = null
@ -1057,19 +1095,26 @@ class PollingWorker private constructor(c : Context) {
// 疑似アカウントはチェック対象外
if(account.isPseudo) return
// アカウントの通知設定が全てオフ
if(! account.notification_mention
&& ! account.notification_boost
&& ! account.notification_favourite
&& ! account.notification_follow
) {
unregisterDeviceToken()
return
client.account = account
val wps = WebPushSubscription(context)
wps.updateSubscription(client, account)
val wps_log = wps.log
if(wps_log.isNotEmpty()) {
log.d("WebPushSubscription: ${account.acct} $wps_log")
}
if(job.isJobCancelled) return
registerDeviceToken()
if(wps.flags == 0) {
unregisterDeviceToken()
return
}
if(! wps.subscribed) {
registerDeviceToken()
}
if(job.isJobCancelled) return
@ -1278,7 +1323,7 @@ class PollingWorker private constructor(c : Context) {
if(now - nr.last_load >= 60000L * 2) {
nr.last_load = now
client.account = account
for(nTry in 0 .. 3) {
if(job.isJobCancelled) return

View File

@ -845,16 +845,31 @@ class TootApiClient(
////////////////////////////////////////////////////////////////////////
// JSONデータ以外を扱うリクエスト
// 疑似アカウントでステータスURLからステータスIDを取得するためにHTMLを取得する
fun getHttp(url : String) : TootApiResult? {
val result = TootApiResult.makeWithCaption(url)
fun http(req:Request) : TootApiResult? {
val result = TootApiResult.makeWithCaption(req.url().host())
if(result.error != null) return result
if(! sendRequest(result, progressPath = url) {
Request.Builder().url(url).build()
}) return result
return parseString(result)
sendRequest(result, progressPath = null) { req }
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? {
val result = http(Request.Builder().url(url).build())
if(result !=null && result.error == null){
parseString(result)
}
return result
}
fun getHttpBytes(url : String) : TootApiResult? {

View File

@ -11,6 +11,7 @@ import android.os.Looper
import android.text.Spannable
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.util.Base64
import android.util.SparseBooleanArray
import android.view.View
import android.view.ViewGroup
@ -25,15 +26,15 @@ import java.io.FileInputStream
import java.io.IOException
import java.lang.ref.WeakReference
import java.security.MessageDigest
import java.util.ArrayList
import java.util.HashMap
import java.util.LinkedList
import java.util.Locale
import java.util.regex.Pattern
import org.apache.commons.io.IOUtils
import org.json.JSONArray
import org.json.JSONObject
import java.security.NoSuchAlgorithmException
import java.util.Locale
import java.util.LinkedList
object Utils {
@ -337,25 +338,25 @@ private fun ByteArray.encodeHex() : String {
return sb.toString()
}
//private fun ByteArray.encodeSHA256() : ByteArray? {
// return try {
// val digest = MessageDigest.getInstance("SHA-256")
// digest.reset()
// digest.digest(this)
// } catch(e1 : NoSuchAlgorithmException) {
// null
// }
//
//}
//
//private fun ByteArray?.encodeBase64Safe() : String? {
// this ?: return null
// return try {
// Base64.encodeToString(this, Base64.URL_SAFE)
// } catch(ex : Throwable) {
// null
// }
//}
fun ByteArray.digestSHA256() : ByteArray? {
return try {
val digest = MessageDigest.getInstance("SHA-256")
digest.reset()
digest.digest(this)
} catch(e1 : NoSuchAlgorithmException) {
null
}
}
fun ByteArray?.encodeBase64Safe() : String? {
this ?: return null
return try {
Base64 .encodeToString(this, Base64.URL_SAFE)
} catch(ex : Throwable) {
null
}
}
////////////////////////////////////////////////////////////////////
// CharSequence

View File

@ -0,0 +1,204 @@
package jp.juggler.subwaytooter.util
import android.content.Context
import jp.juggler.subwaytooter.PollingWorker
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.table.SavedAccount
import okhttp3.Request
import okhttp3.RequestBody
import org.json.JSONObject
class WebPushSubscription(val context : Context,val verbose: Boolean = false) {
var enabled : Boolean = false
var subscribed : Boolean = false
var flags = 0
val log : String
get() = sb.toString()
private val sb = StringBuilder()
private fun addLog(s : String) {
if(sb.isNotEmpty()) sb.append('\n')
sb.append(s)
}
private fun updateSubscription_sub(client : TootApiClient, account : SavedAccount) : TootApiResult? {
try {
if(account.notification_boost) flags += 1
if(account.notification_favourite) flags += 2
if(account.notification_follow) flags += 4
if(account.notification_mention) flags += 8
// 疑似アカウントの確認
if(account.isPseudo) {
return TootApiResult(error = context.getString(R.string.pseudo_account_not_supported))
}
// インスタンスバージョンの確認
var r = client.getInstanceInformation2()
val ti = r?.data as? TootInstance ?: return r
if(! ti.isEnoughVersion(TootInstance.VERSION_2_4)) {
return TootApiResult(error = context.getString(R.string.instance_does_not_support_push_api,ti.version))
}
// FCMのデバイスIDを取得
val device_id = PollingWorker.getDeviceId(context)
?: return TootApiResult(error = context.getString(R.string.missing_fcm_device_id))
// インストールIDを取得
val install_id = PollingWorker.prepareInstallId(context)
?: return TootApiResult(error = context.getString(R.string.missing_install_id))
// アクセストークンの優先権を取得
r = client.http(
Request.Builder()
.url("${PollingWorker.APP_SERVER}/webpushtokencheck")
.post(
RequestBody.create(
TootApiClient.MEDIA_TYPE_JSON,
JSONObject().also {
it.put("token_digest", account.getAccessToken()?.digestSHA256())
it.put("install_id", install_id)
}.toString()
)
)
.build()
)
var res = r?.response ?: return r
if(res.code() != 200) {
return TootApiResult(error = context.getString(R.string.token_exported))
}
enabled = true
// TODO 現在の購読状態を取得できれば良いのに…
if(flags == 0) {
// delete subscription
r = client.request(
"/api/v1/push/subscription",
Request.Builder().delete()
)
res = r?.response ?: return r
when(res.code()) {
200 -> {
addLog(context.getString(R.string.push_subscription_deleted))
return r
}
404 -> {
enabled = false
return if(verbose){
addLog(context.getString(R.string.missing_push_api))
r
}else{
// バックグラウンド実行時は別にコレでも構わないので正常終了扱いとする
TootApiResult()
}
}
403 -> {
enabled = false
return if(verbose){
addLog(context.getString(R.string.missing_push_scope))
r
}else{
// バックグラウンド実行時は別にコレでも構わないので正常終了扱いとする
TootApiResult()
}
}
}
addLog("${res.request()}")
addLog("${res.code()} ${res.message()}")
val json = r?.jsonObject
if(json != null) {
addLog(json.toString())
}
return r
}
// FCM経由での配信に必要なパラメータ
val endpoint =
"${PollingWorker.APP_SERVER}/webpushcallback/${device_id.encodePercent()}/${account.acct.encodePercent()}/$flags"
// プッシュ通知の登録
var json : JSONObject? = JSONObject().also {
it.put("subscription", JSONObject().also {
it.put("endpoint", endpoint )
it.put("keys", JSONObject().also {
it.put(
"p256dh",
"BBEUVi7Ehdzzpe_ZvlzzkQnhujNJuBKH1R0xYg7XdAKNFKQG9Gpm0TSGRGSuaU7LUFKX-uz8YW0hAshifDCkPuE"
)
it.put("auth", "iRdmDrOS6eK6xvG1H6KshQ")
})
})
it.put("data", JSONObject().also {
it.put("alerts", JSONObject().also {
it.put("follow", account.notification_follow)
it.put("favourite", account.notification_favourite)
it.put("reblog", account.notification_boost)
it.put("mention", account.notification_mention)
})
})
}
r = client.request(
"/api/v1/push/subscription",
Request.Builder().post(
RequestBody.create(
TootApiClient.MEDIA_TYPE_JSON,
json.toString()
)
)
)
res = r?.response ?: return r
when(res.code()) {
404 -> {
addLog(context.getString(R.string.missing_push_api))
return r
}
403 -> {
addLog(context.getString(R.string.missing_push_scope))
return r
}
200 -> {
subscribed = true
addLog(context.getString(R.string.push_subscription_updated))
return r
}
}
json = r?.jsonObject
if(json != null) {
addLog(json.toString())
}
return r
} catch(ex : Throwable) {
return TootApiResult(error = ex.withCaption("error."))
}
}
fun updateSubscription(client : TootApiClient, account : SavedAccount) : TootApiResult? {
val result = updateSubscription_sub(client, account)
val e = result?.error
if(e != null) addLog(e)
return result
}
}

View File

@ -359,17 +359,7 @@
/>
</LinearLayout>
<LinearLayout style="@style/setting_row_form">
<Button
android:id="@+id/btnPushTest"
style="@style/setting_horizontal_stretch"
android:ellipsize="start"
android:text="@string/push_notification_test"
android:textAllCaps="false"
/>
</LinearLayout>
<LinearLayout style="@style/setting_row_form">
<Button
@ -582,6 +572,18 @@
/>
</LinearLayout>
<LinearLayout style="@style/setting_row_form">
<Button
android:id="@+id/btnPushSubscription"
style="@style/setting_horizontal_stretch"
android:ellipsize="start"
android:text="@string/update_push_subscription"
android:textAllCaps="false"
/>
</LinearLayout>
<View style="@style/setting_divider"/>
<TextView

View File

@ -659,9 +659,18 @@
<string name="available_mastodon_2_4_later">(available in Mastodon 2.4 or later)</string>
<string name="confirm_boost_private_from">Boost private status from %1$s ? It\'s shown to all followers.</string>
<string name="boost_private_toot_not_allowed">You can\'t boost private toot by another person.</string>
<string name="push_notification_test">Push Notification Test (Mastodon 2.4 or later)</string>
<string name="update_push_subscription">Update push subscription (Mastodon 2.4 or later)</string>
<string name="pseudo_account_not_supported">Pseudo account can\'t use notifications.</string>
<string name="instance_does_not_support_push_api">Instance version %1$s too old. that does not support Push API.</string>
<string name="missing_fcm_device_id">Can\'t prepare FCM device ID. please retry later.</string>
<string name="missing_install_id">Can\'t prepare install ID. please retry later.</string>
<string name="token_exported">Due to exporting application data or restoring from backup, this access token may be used by other devices as well. Because access tokens that are not used by other devices are required to use push notifications, we recommend updating the access token.</string>
<string name="push_subscription_deleted">Push subscription deleted.</string>
<string name="missing_push_api">The instance has no Push API endpoint.</string>
<string name="missing_push_scope">Your access token does not contains push scope. updating access token is recommended.</string>
<string name="push_subscription_updated">Push subscription updated.</string>
<!--<string name="abc_action_bar_home_description">Revenir à l\'accueil</string>-->
<!--<string name="abc_action_bar_home_description">Revenir à l\'accueil</string>-->
<!--<string name="abc_action_bar_home_description_format">%1$s, %2$s</string>-->
<!--<string name="abc_action_bar_home_subtitle_description_format">%1$s, %2$s, %3$s</string>-->
<!--<string name="abc_action_bar_up_description">Revenir en haut de la page</string>-->

View File

@ -938,6 +938,15 @@
<string name="available_mastodon_2_4_later">(マストドン2.4以降で利用可能)</string>
<string name="confirm_boost_private_from">非公開トゥートを %1$s からブーストしますか? 全てのフォロワーに公開されます</string>
<string name="boost_private_toot_not_allowed">非公開トゥートをブーストできるのは本人だけです</string>
<string name="push_notification_test">Push Notification Test (Mastodon 2.4 or later)</string>
<string name="update_push_subscription">プッシュ通知購読の更新 (Mastodon2.4以降)</string>
<string name="pseudo_account_not_supported">疑似アカウントでは通知を利用できません。</string>
<string name="instance_does_not_support_push_api">タンスのバージョン %1$s は古くてプッシュAPIを利用できません</string>
<string name="missing_fcm_device_id">FCMのデバイスIDを準備できません。しばらく後に試してください。</string>
<string name="missing_install_id">インストールIDを準備できません。しばらく後に試してください。</string>
<string name="token_exported">アプリデータのエクスポート、インポート、バックアップからの復元などでアクセストークンが他のデバイスでも使われている可能性があります。アクセストークンの更新をおすすめします。</string>
<string name="push_subscription_deleted">プッシュ通知の購読を取り消しました。</string>
<string name="missing_push_api">このタンスはなぜかプッシュAPIがありません。</string>
<string name="missing_push_scope">このアクセストークンは古いのでプッシュAPIを利用する権限がありません。アクセストークンの更新をおすすめします。</string>
<string name="push_subscription_updated">プッシュ通知の購読を更新しました。</string>
</resources>

View File

@ -645,5 +645,14 @@
<string name="available_mastodon_2_4_later">(available in Mastodon 2.4 or later)</string>
<string name="confirm_boost_private_from">Boost private status from %1$s ? It\'s shown to all followers.</string>
<string name="boost_private_toot_not_allowed">You can\'t boost private toot by another person.</string>
<string name="push_notification_test">Push Notification Test (Mastodon 2.4 or later)</string>
<string name="update_push_subscription">Update push subscription (Mastodon 2.4 or later)</string>
<string name="pseudo_account_not_supported">Pseudo account can\'t use notifications.</string>
<string name="instance_does_not_support_push_api">Instance version %1$s too old. that does not support Push API.</string>
<string name="missing_fcm_device_id">Can\'t prepare FCM device ID. please retry later.</string>
<string name="missing_install_id">Can\'t prepare install ID. please retry later.</string>
<string name="token_exported">Due to exporting application data or restoring from backup, this access token may be used by other devices as well. Because access tokens that are not used by other devices are required to use push notifications, we recommend updating the access token.</string>
<string name="push_subscription_deleted">Push subscription deleted.</string>
<string name="missing_push_api">The instance has no Push API endpoint.</string>
<string name="missing_push_scope">Your access token does not contains push scope. updating access token is recommended.</string>
<string name="push_subscription_updated">Push subscription updated.</string>
</resources>