Misskeyでプッシュ通知の動作確認を行った。

This commit is contained in:
tateisu 2023-02-08 18:55:49 +09:00
parent ecbed39f5b
commit 995e7a7504
62 changed files with 1067 additions and 548 deletions

View File

@ -46,6 +46,5 @@ dependencies {
api project(":apng") api project(":apng")
implementation project(":base") implementation project(":base")
implementation "com.github.zjupure:webpdecoder:2.3.$glideVersion"
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
} }

View File

@ -21,3 +21,7 @@
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-dontobfuscate -dontobfuscate
-keep public class com.bumptech.glide.integration.webp.WebpImage { *; }
-keep public class com.bumptech.glide.integration.webp.WebpFrame { *; }
-keep public class com.bumptech.glide.integration.webp.WebpBitmapFactory { *; }

View File

@ -68,6 +68,12 @@ android {
lintOptions { lintOptions {
disable "MissingTranslation" disable "MissingTranslation"
} }
// You need to specify either an absolute path or include the
// keystore file in the same directory as the build.gradle file.
storeFile file("D:\\GoogleDrive\\_private\\AndroidSignKeys\\SubwayTooter.jks")
storePassword "password"
keyAlias "my-alias"
keyPassword "password"
} }
debug { debug {

View File

@ -60,6 +60,10 @@
kotlinx.serialization.KSerializer serializer(...); kotlinx.serialization.KSerializer serializer(...);
} }
-keep public class com.bumptech.glide.integration.webp.WebpImage { *; }
-keep public class com.bumptech.glide.integration.webp.WebpFrame { *; }
-keep public class com.bumptech.glide.integration.webp.WebpBitmapFactory { *; }
# keep everything # keep everything
-keep class ** { *; } -keep class ** { *; }
-keepclassmembers class ** { *** Companion; } -keepclassmembers class ** { *** Companion; }

View File

@ -1,8 +1,12 @@
package jp.juggler.subwaytooter.api package jp.juggler.subwaytooter.api
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.entity.TootAccount.Companion.tootAccount
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRef
import jp.juggler.subwaytooter.api.entity.TootNotification.Companion.tootNotification
import jp.juggler.subwaytooter.api.entity.TootStatus.Companion.tootStatus
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.data.* import jp.juggler.util.data.*
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -36,7 +40,7 @@ class TestDuplicateMap {
if (url != null) put("url", url) if (url != null) put("url", url)
} }
return TootStatus(parser, itemJson) return tootStatus(parser, itemJson)
} }
private fun testDuplicateStatus(): ArrayList<TimelineItem> { private fun testDuplicateStatus(): ArrayList<TimelineItem> {
@ -63,7 +67,7 @@ class TestDuplicateMap {
put("url", "http://${parser.apiHost}/@user1") put("url", "http://${parser.apiHost}/@user1")
} }
val account1 = TootAccount(parser, account1Json) val account1 = tootAccount(parser, account1Json)
assertNotNull(account1) assertNotNull(account1)
val map = DuplicateMap() val map = DuplicateMap()
@ -122,7 +126,7 @@ class TestDuplicateMap {
put("id", id) put("id", id)
} }
val item = TootNotification(parser, itemJson) val item = tootNotification(parser, itemJson)
assertNotNull(item) assertNotNull(item)
generatedItems.add(item) generatedItems.add(item)
assertEquals(false, map.isDuplicate(item)) assertEquals(false, map.isDuplicate(item))
@ -178,7 +182,7 @@ class TestDuplicateMap {
put("url", "http://${parser.apiHost}/@user$id") put("url", "http://${parser.apiHost}/@user$id")
} }
val item = TootAccountRef.notNull(parser, TootAccount(parser, itemJson)) val item = tootAccountRef(parser, tootAccount(parser, itemJson))
assertNotNull(item) assertNotNull(item)
generatedItems.add(item) generatedItems.add(item)
assertEquals(false, map.isDuplicate(item)) assertEquals(false, map.isDuplicate(item))

View File

@ -1,7 +1,7 @@
package jp.juggler.subwaytooter.api.entity package jp.juggler.subwaytooter.api.entity
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.data.* import jp.juggler.util.data.*
@ -30,42 +30,42 @@ class TestEntityUtils {
@Test @Test
fun testParseItem() { fun testParseItem() {
assertEquals(null, parseItem(::TestEntity, null)) assertEquals(null, parseItem(null, ::TestEntity))
run { run {
val src = """{"s":null,"l":"100"}""".decodeJsonObject() val src = """{"s":null,"l":"100"}""".decodeJsonObject()
val item = parseItem(::TestEntity, src) val item = parseItem(src, ::TestEntity)
assertNull(item) assertNull(item)
} }
run { run {
val src = """{"s":"","l":"100"}""".decodeJsonObject() val src = """{"s":"","l":"100"}""".decodeJsonObject()
val item = parseItem(::TestEntity, src) val item = parseItem(src, ::TestEntity)
assertNull(item) assertNull(item)
} }
run { run {
val src = """{"s":"A","l":null}""".decodeJsonObject() val src = """{"s":"A","l":null}""".decodeJsonObject()
val item = parseItem(::TestEntity, src) val item = parseItem(src, ::TestEntity)
assertNotNull(item) assertNotNull(item)
assertEquals(src.optString("s"), item?.s) assertEquals(src.optString("s"), item?.s)
assertEquals(src.optLong("l"), item?.l) assertEquals(src.optLong("l"), item?.l)
} }
run { run {
val src = """{"s":"A","l":""}""".decodeJsonObject() val src = """{"s":"A","l":""}""".decodeJsonObject()
val item = parseItem(::TestEntity, src) val item = parseItem(src, ::TestEntity)
assertNotNull(item) assertNotNull(item)
assertEquals(src.optString("s"), item?.s) assertEquals(src.optString("s"), item?.s)
assertEquals(src.optLong("l"), item?.l) assertEquals(src.optLong("l"), item?.l)
} }
run { run {
val src = """{"s":"A","l":100}""".decodeJsonObject() val src = """{"s":"A","l":100}""".decodeJsonObject()
val item = parseItem(::TestEntity, src) val item = parseItem(src, ::TestEntity)
assertNotNull(item) assertNotNull(item)
assertEquals(src.optString("s"), item?.s) assertEquals(src.optString("s"), item?.s)
assertEquals(src.optLong("l"), item?.l) assertEquals(src.optLong("l"), item?.l)
} }
run { run {
val src = """{"s":"A","l":"100"}""".decodeJsonObject() val src = """{"s":"A","l":"100"}""".decodeJsonObject()
val item = parseItem(::TestEntity, src) val item = parseItem(src, ::TestEntity)
assertNotNull(item) assertNotNull(item)
assertEquals(src.optString("s"), item?.s) assertEquals(src.optString("s"), item?.s)
assertEquals(src.optLong("l"), item?.l) assertEquals(src.optLong("l"), item?.l)
@ -74,74 +74,74 @@ class TestEntityUtils {
@Test @Test
fun testParseList() { fun testParseList() {
assertEquals(0, parseList(::TestEntity, null).size) assertEquals(0, parseList(null, ::TestEntity).size)
val src = JsonArray() val src = JsonArray()
assertEquals(0, parseList(::TestEntity, src).size) assertEquals(0, parseList(src, ::TestEntity).size)
src.add("""{"s":"A","l":"100"}""".decodeJsonObject()) src.add("""{"s":"A","l":"100"}""".decodeJsonObject())
assertEquals(1, parseList(::TestEntity, src).size) assertEquals(1, parseList(src, ::TestEntity).size)
src.add("""{"s":"A","l":"100"}""".decodeJsonObject()) src.add("""{"s":"A","l":"100"}""".decodeJsonObject())
assertEquals(2, parseList(::TestEntity, src).size) assertEquals(2, parseList(src, ::TestEntity).size)
// error // error
src.add("""{"s":"","l":"100"}""".decodeJsonObject()) src.add("""{"s":"","l":"100"}""".decodeJsonObject())
assertEquals(2, parseList(::TestEntity, src).size) assertEquals(2, parseList(src, ::TestEntity).size)
} }
@Test @Test
fun testParseListOrNull() { fun testParseListOrNull() {
assertEquals(null, parseListOrNull(::TestEntity, null)) assertEquals(null, parseListOrNull(null, ::TestEntity))
val src = JsonArray() val src = JsonArray()
assertEquals(null, parseListOrNull(::TestEntity, src)) assertEquals(null, parseListOrNull(src, ::TestEntity))
src.add("""{"s":"A","l":"100"}""".decodeJsonObject()) src.add("""{"s":"A","l":"100"}""".decodeJsonObject())
assertEquals(1, parseListOrNull(::TestEntity, src)?.size) assertEquals(1, parseListOrNull(src, ::TestEntity)?.size)
src.add("""{"s":"A","l":"100"}""".decodeJsonObject()) src.add("""{"s":"A","l":"100"}""".decodeJsonObject())
assertEquals(2, parseListOrNull(::TestEntity, src)?.size) assertEquals(2, parseListOrNull(src, ::TestEntity)?.size)
// error // error
src.add("""{"s":"","l":"100"}""".decodeJsonObject()) src.add("""{"s":"","l":"100"}""".decodeJsonObject())
assertEquals(2, parseListOrNull(::TestEntity, src)?.size) assertEquals(2, parseListOrNull(src, ::TestEntity)?.size)
} }
@Test @Test
fun testParseMap() { fun testParseMap() {
assertEquals(0, parseMap(::TestEntity, null).size) assertEquals(0, parseMap(null, ::TestEntity).size)
val src = JsonArray() val src = JsonArray()
assertEquals(0, parseMap(::TestEntity, src).size) assertEquals(0, parseMap(null, ::TestEntity).size)
src.add("""{"s":"A","l":"100"}""".decodeJsonObject()) src.add("""{"s":"A","l":"100"}""".decodeJsonObject())
assertEquals(1, parseMap(::TestEntity, src).size) assertEquals(1, parseMap(src, ::TestEntity).size)
src.add("""{"s":"B","l":"100"}""".decodeJsonObject()) src.add("""{"s":"B","l":"100"}""".decodeJsonObject())
assertEquals(2, parseMap(::TestEntity, src).size) assertEquals(2, parseMap(src, ::TestEntity).size)
// error // error
src.add("""{"s":"","l":"100"}""".decodeJsonObject()) src.add("""{"s":"","l":"100"}""".decodeJsonObject())
assertEquals(2, parseMap(::TestEntity, src).size) assertEquals(2, parseMap(src, ::TestEntity).size)
} }
@Test @Test
fun testParseMapOrNull() { fun testParseMapOrNull() {
assertEquals(null, parseMapOrNull(::TestEntity, null)) assertEquals(null, parseMapOrNull(null, ::TestEntity))
val src = JsonArray() val src = JsonArray()
assertEquals(null, parseMapOrNull(::TestEntity, src)) assertEquals(null, parseMapOrNull(src, ::TestEntity))
src.add("""{"s":"A","l":"100"}""".decodeJsonObject()) src.add("""{"s":"A","l":"100"}""".decodeJsonObject())
assertEquals(1, parseMapOrNull(::TestEntity, src)?.size) assertEquals(1, parseMapOrNull(src, ::TestEntity)?.size)
src.add("""{"s":"B","l":"100"}""".decodeJsonObject()) src.add("""{"s":"B","l":"100"}""".decodeJsonObject())
assertEquals(2, parseMapOrNull(::TestEntity, src)?.size) assertEquals(2, parseMapOrNull(src, ::TestEntity)?.size)
// error // error
src.add("""{"s":"","l":"100"}""".decodeJsonObject()) src.add("""{"s":"","l":"100"}""".decodeJsonObject())
assertEquals(2, parseMapOrNull(::TestEntity, src)?.size) assertEquals(2, parseMapOrNull(src, ::TestEntity)?.size)
} }
private val parser by lazy { private val parser by lazy {
@ -154,42 +154,42 @@ class TestEntityUtils {
@Test @Test
fun testParseItemWithParser() { fun testParseItemWithParser() {
assertEquals(null, parseItem(::TestEntity, parser, null)) assertEquals(null, parseItem(null) { TestEntity(parser, it) })
run { run {
val src = """{"s":null,"l":"100"}""".decodeJsonObject() val src = """{"s":null,"l":"100"}""".decodeJsonObject()
val item = parseItem(::TestEntity, parser, src) val item = parseItem(src) { TestEntity(parser, it) }
assertNull(item) assertNull(item)
} }
run { run {
val src = """{"s":"","l":"100"}""".decodeJsonObject() val src = """{"s":"","l":"100"}""".decodeJsonObject()
val item = parseItem(::TestEntity, parser, src) val item = parseItem(src) { TestEntity(parser, it) }
assertNull(item) assertNull(item)
} }
run { run {
val src = """{"s":"A","l":null}""".decodeJsonObject() val src = """{"s":"A","l":null}""".decodeJsonObject()
val item = parseItem(::TestEntity, parser, src) val item = parseItem(src) { TestEntity(parser, it) }
assertNotNull(item) assertNotNull(item)
assertEquals(src.optString("s"), item?.s) assertEquals(src.optString("s"), item?.s)
assertEquals(src.optLong("l"), item?.l) assertEquals(src.optLong("l"), item?.l)
} }
run { run {
val src = """{"s":"A","l":""}""".decodeJsonObject() val src = """{"s":"A","l":""}""".decodeJsonObject()
val item = parseItem(::TestEntity, parser, src) val item = parseItem(src) { TestEntity(parser, it) }
assertNotNull(item) assertNotNull(item)
assertEquals(src.optString("s"), item?.s) assertEquals(src.optString("s"), item?.s)
assertEquals(src.optLong("l"), item?.l) assertEquals(src.optLong("l"), item?.l)
} }
run { run {
val src = """{"s":"A","l":100}""".decodeJsonObject() val src = """{"s":"A","l":100}""".decodeJsonObject()
val item = parseItem(::TestEntity, parser, src) val item = parseItem(src) { TestEntity(parser, it) }
assertNotNull(item) assertNotNull(item)
assertEquals(src.optString("s"), item?.s) assertEquals(src.optString("s"), item?.s)
assertEquals(src.optLong("l"), item?.l) assertEquals(src.optLong("l"), item?.l)
} }
run { run {
val src = """{"s":"A","l":"100"}""".decodeJsonObject() val src = """{"s":"A","l":"100"}""".decodeJsonObject()
val item = parseItem(::TestEntity, parser, src) val item = parseItem(src) { TestEntity(parser, it) }
assertNotNull(item) assertNotNull(item)
assertEquals(src.optString("s"), item?.s) assertEquals(src.optString("s"), item?.s)
assertEquals(src.optLong("l"), item?.l) assertEquals(src.optLong("l"), item?.l)
@ -198,38 +198,38 @@ class TestEntityUtils {
@Test @Test
fun testParseListWithParser() { fun testParseListWithParser() {
assertEquals(0, parseList(::TestEntity, parser, null).size) assertEquals(0, parseList(null) { TestEntity(parser, it) }.size)
val src = JsonArray() val src = JsonArray()
assertEquals(0, parseList(::TestEntity, parser, src).size) assertEquals(0, parseList(src) { TestEntity(parser, it) }.size)
src.add("""{"s":"A","l":"100"}""".decodeJsonObject()) src.add("""{"s":"A","l":"100"}""".decodeJsonObject())
assertEquals(1, parseList(::TestEntity, parser, src).size) assertEquals(1, parseList(src) { TestEntity(parser, it) }.size)
src.add("""{"s":"A","l":"100"}""".decodeJsonObject()) src.add("""{"s":"A","l":"100"}""".decodeJsonObject())
assertEquals(2, parseList(::TestEntity, parser, src).size) assertEquals(2, parseList(src) { TestEntity(parser, it) }.size)
// error // error
src.add("""{"s":"","l":"100"}""".decodeJsonObject()) src.add("""{"s":"","l":"100"}""".decodeJsonObject())
assertEquals(2, parseList(::TestEntity, parser, src).size) assertEquals(2, parseList(src) { TestEntity(parser, it) }.size)
} }
@Test @Test
fun testParseListOrNullWithParser() { fun testParseListOrNullWithParser() {
assertEquals(null, parseListOrNull(::TestEntity, parser, null)) assertEquals(null, parseListOrNull(null) { TestEntity(parser, it) })
val src = JsonArray() val src = JsonArray()
assertEquals(null, parseListOrNull(::TestEntity, parser, src)) assertEquals(null, parseListOrNull(src) { TestEntity(parser, it) })
src.add("""{"s":"A","l":"100"}""".decodeJsonObject()) src.add("""{"s":"A","l":"100"}""".decodeJsonObject())
assertEquals(1, parseListOrNull(::TestEntity, parser, src)?.size) assertEquals(1, parseListOrNull(src) { TestEntity(parser, it) }?.size)
src.add("""{"s":"A","l":"100"}""".decodeJsonObject()) src.add("""{"s":"A","l":"100"}""".decodeJsonObject())
assertEquals(2, parseListOrNull(::TestEntity, parser, src)?.size) assertEquals(2, parseListOrNull(src) { TestEntity(parser, it) }?.size)
// error // error
src.add("""{"s":"","l":"100"}""".decodeJsonObject()) src.add("""{"s":"","l":"100"}""".decodeJsonObject())
assertEquals(2, parseListOrNull(::TestEntity, parser, src)?.size) assertEquals(2, parseListOrNull(src) { TestEntity(parser, it) }?.size)
} }
@Test(expected = RuntimeException::class) @Test(expected = RuntimeException::class)

View File

@ -5,6 +5,7 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
@ -16,12 +17,16 @@ import android.view.View
import android.widget.* import android.widget.*
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.jrummyapps.android.colorpicker.ColorPickerDialog
import com.jrummyapps.android.colorpicker.ColorPickerDialogListener
import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.auth.AuthBase import jp.juggler.subwaytooter.api.auth.AuthBase
import jp.juggler.subwaytooter.api.auth.authRepo import jp.juggler.subwaytooter.api.auth.authRepo
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment
import jp.juggler.subwaytooter.databinding.ActAccountSettingBinding import jp.juggler.subwaytooter.databinding.ActAccountSettingBinding
import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm
import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.dialog.actionsDialog
import jp.juggler.subwaytooter.notification.* import jp.juggler.subwaytooter.notification.*
import jp.juggler.subwaytooter.push.PushBase import jp.juggler.subwaytooter.push.PushBase
@ -64,6 +69,7 @@ import kotlin.math.max
class ActAccountSetting : AppCompatActivity(), class ActAccountSetting : AppCompatActivity(),
View.OnClickListener, View.OnClickListener,
ColorPickerDialogListener,
CompoundButton.OnCheckedChangeListener, CompoundButton.OnCheckedChangeListener,
AdapterView.OnItemSelectedListener { AdapterView.OnItemSelectedListener {
companion object { companion object {
@ -84,6 +90,8 @@ class ActAccountSetting : AppCompatActivity(),
private const val ACTIVITY_STATE = "MyActivityState" private const val ACTIVITY_STATE = "MyActivityState"
private const val COLOR_DIALOG_NOTIFICATION_ACCENT_COLOR = 1
fun createIntent(activity: Activity, ai: SavedAccount) = fun createIntent(activity: Activity, ai: SavedAccount) =
Intent(activity, ActAccountSetting::class.java).apply { Intent(activity, ActAccountSetting::class.java).apply {
putExtra(KEY_ACCOUNT_DB_ID, ai.db_id) putExtra(KEY_ACCOUNT_DB_ID, ai.db_id)
@ -510,6 +518,8 @@ class ActAccountSetting : AppCompatActivity(),
spResizeImage, spResizeImage,
swNotificationPullEnabled, swNotificationPullEnabled,
swNotificationPushEnabled, swNotificationPushEnabled,
btnNotificationAccentColorEdit,
btnNotificationAccentColorReset,
).forEach { it.isEnabledAlpha = enabled } ).forEach { it.isEnabledAlpha = enabled }
// arrayOf( // arrayOf(
@ -521,6 +531,7 @@ class ActAccountSetting : AppCompatActivity(),
showVisibility() showVisibility()
showAcctColor() showAcctColor()
showPushSetting() showPushSetting()
showNotificationColor()
} finally { } finally {
loadingBusy = false loadingBusy = false
} }
@ -547,6 +558,8 @@ class ActAccountSetting : AppCompatActivity(),
tvPushActions.vg(usePush) tvPushActions.vg(usePush)
btnPushSubscription.vg(usePush) btnPushSubscription.vg(usePush)
btnPushSubscriptionNotForce.vg(usePush) btnPushSubscriptionNotForce.vg(usePush)
tvNotificationAccentColor.vg(usePush)
llNotificationAccentColor.vg(usePush)
} }
run { run {
@ -688,6 +701,22 @@ class ActAccountSetting : AppCompatActivity(),
// PullNotification.openNotificationChannelSetting( // PullNotification.openNotificationChannelSetting(
// this // this
// ) // )
R.id.btnNotificationAccentColorEdit -> {
ColorPickerDialog.newBuilder().apply {
setDialogType(ColorPickerDialog.TYPE_CUSTOM)
setAllowPresets(true)
setShowAlphaSlider(false)
setDialogId(COLOR_DIALOG_NOTIFICATION_ACCENT_COLOR)
account.notificationAccentColor.notZero()?.let { setColor(it) }
}.show(this)
}
R.id.btnNotificationAccentColorReset -> {
account.notificationAccentColor = 0
saveUIToData()
showNotificationColor()
}
} }
} }
@ -788,16 +817,11 @@ class ActAccountSetting : AppCompatActivity(),
/////////////////////////////////////////////////// ///////////////////////////////////////////////////
private fun performAccountRemove() { private fun performAccountRemove() {
AlertDialog.Builder(this) launchAndShowError {
.setTitle(R.string.confirm) confirm(getString(R.string.confirm_account_remove), title = getString(R.string.confirm))
.setMessage(R.string.confirm_account_remove) authRepo.accountRemove(account)
.setNegativeButton(R.string.cancel, null) finish()
.setPositiveButton(R.string.ok) { _, _ -> }
launchAndShowError {
authRepo.accountRemove(account)
finish()
}
}.show()
} }
/////////////////////////////////////////////////// ///////////////////////////////////////////////////
@ -1525,4 +1549,24 @@ class ActAccountSetting : AppCompatActivity(),
.show() .show()
} }
} }
override fun onDialogDismissed(dialogId: Int) {
}
override fun onColorSelected(dialogId: Int, newColor: Int) {
when (dialogId) {
COLOR_DIALOG_NOTIFICATION_ACCENT_COLOR -> {
account.notificationAccentColor = newColor or Color.BLACK
showNotificationColor()
saveUIToData()
}
else -> Unit
}
}
private fun showNotificationColor() {
views.vNotificationAccentColorColor.backgroundColor =
account.notificationAccentColor.notZero()
?: ContextCompat.getColor(this, R.color.colorOsNotificationAccent)
}
} }

View File

@ -15,7 +15,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import jp.juggler.subwaytooter.api.dialogOrToast import jp.juggler.subwaytooter.api.dialogOrToast
import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.subwaytooter.databinding.ActPushMessageListBinding import jp.juggler.subwaytooter.databinding.ActPushMessageListBinding
import jp.juggler.subwaytooter.databinding.LvPushMessageBinding import jp.juggler.subwaytooter.databinding.LvPushMessageBinding
import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.dialog.actionsDialog
@ -26,12 +25,14 @@ import jp.juggler.subwaytooter.push.pushRepo
import jp.juggler.subwaytooter.table.PushMessage import jp.juggler.subwaytooter.table.PushMessage
import jp.juggler.subwaytooter.table.daoAccountNotificationStatus import jp.juggler.subwaytooter.table.daoAccountNotificationStatus
import jp.juggler.subwaytooter.table.daoPushMessage import jp.juggler.subwaytooter.table.daoPushMessage
import jp.juggler.subwaytooter.table.daoSavedAccount
import jp.juggler.subwaytooter.util.permissionSpecNotification import jp.juggler.subwaytooter.util.permissionSpecNotification
import jp.juggler.subwaytooter.util.requester import jp.juggler.subwaytooter.util.requester
import jp.juggler.util.coroutine.AppDispatchers import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.data.encodeBase64Url import jp.juggler.util.data.encodeBase64Url
import jp.juggler.util.data.notBlank import jp.juggler.util.data.notBlank
import jp.juggler.util.data.notZero
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import jp.juggler.util.os.saveToDownload import jp.juggler.util.os.saveToDownload
import jp.juggler.util.time.formatLocalTime import jp.juggler.util.time.formatLocalTime
@ -59,6 +60,9 @@ class ActPushMessageList : AppCompatActivity() {
private val prNotification = permissionSpecNotification.requester { private val prNotification = permissionSpecNotification.requester {
// 特に何もしない // 特に何もしない
} }
private val acctMap by lazy {
daoSavedAccount.loadRealAccounts().associateBy { it.acct }
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
prNotification.register(this) prNotification.register(this)
@ -96,7 +100,7 @@ class ActPushMessageList : AppCompatActivity() {
launchAndShowError { launchAndShowError {
actionsDialog { actionsDialog {
action(getString(R.string.push_message_re_decode)) { action(getString(R.string.push_message_re_decode)) {
pushRepo.reDecode(pm) pushRepo.reprocess(pm)
} }
action(getString(R.string.push_message_save_to_download_folder)) { action(getString(R.string.push_message_save_to_download_folder)) {
export(pm) export(pm)
@ -140,7 +144,7 @@ class ActPushMessageList : AppCompatActivity() {
if (acct == null) { if (acct == null) {
println("!!secret key is not exported because missing recepients acct.") println("!!secret key is not exported because missing recepients acct.")
} else { } else {
val status = daoAccountNotificationStatus.load(Acct.parse(acct)) val status = daoAccountNotificationStatus.load(acct)
if (status == null) { if (status == null) {
println("!!secret key is not exported because missing status for acct $acct .") println("!!secret key is not exported because missing status for acct $acct .")
} else { } else {
@ -157,12 +161,16 @@ class ActPushMessageList : AppCompatActivity() {
private val tintIconMap = HashMap<String, Drawable>() private val tintIconMap = HashMap<String, Drawable>()
fun tintIcon(ic: PushMessageIconColor) = fun tintIcon(pm: PushMessage, ic: PushMessageIconColor) =
tintIconMap.getOrPut(ic.name) { tintIconMap.getOrPut("${ic.name}-${pm.loginAcct}") {
val context = this val context = this
val src = ContextCompat.getDrawable(context, ic.iconId)!! val src = ContextCompat.getDrawable(context, ic.iconId)!!
DrawableCompat.wrap(src).also { DrawableCompat.wrap(src).also { d ->
DrawableCompat.setTint(it, ContextCompat.getColor(context, ic.colorRes)) val a = acctMap[pm.loginAcct]
val c = ic.colorRes.notZero()?.let { ContextCompat.getColor(context, it) }
?: a?.notificationAccentColor?.notZero()
?: ContextCompat.getColor(this, R.color.colorOsNotificationAccent)
DrawableCompat.setTint(d, c)
} }
} }
@ -183,10 +191,9 @@ class ActPushMessageList : AppCompatActivity() {
pm ?: return pm ?: return
lastItem = pm lastItem = pm
val iconAndColor = pm.iconColor()
Glide.with(views.ivSmall) Glide.with(views.ivSmall)
.load(pm.iconSmall) .load(pm.iconSmall)
.error(tintIcon(iconAndColor)) .error(tintIcon(pm, pm.iconColor()))
.into(views.ivSmall) .into(views.ivSmall)
Glide.with(views.ivLarge) Glide.with(views.ivLarge)

View File

@ -1,22 +1,29 @@
package jp.juggler.subwaytooter package jp.juggler.subwaytooter
import android.content.Context import android.content.Context
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.PictureDrawable import android.graphics.drawable.PictureDrawable
import androidx.annotation.Nullable
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.GlideBuilder import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.Registry import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.integration.webp.decoder.*
import com.bumptech.glide.load.Options import com.bumptech.glide.load.Options
import com.bumptech.glide.load.ResourceDecoder import com.bumptech.glide.load.ResourceDecoder
import com.bumptech.glide.load.engine.Resource import com.bumptech.glide.load.engine.Resource
import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.SimpleResource import com.bumptech.glide.load.resource.SimpleResource
import com.bumptech.glide.load.resource.bitmap.BitmapDrawableDecoder
import com.bumptech.glide.load.resource.transcode.ResourceTranscoder import com.bumptech.glide.load.resource.transcode.ResourceTranscoder
import com.bumptech.glide.module.AppGlideModule import com.bumptech.glide.module.AppGlideModule
import com.caverock.androidsvg.SVG import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException import com.caverock.androidsvg.SVGParseException
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.nio.ByteBuffer
import kotlin.math.min import kotlin.math.min
@GlideModule @GlideModule
@ -73,7 +80,6 @@ class MyAppGlideModule : AppGlideModule() {
// Convert the [SVG]'s internal representation to an Android-compatible one ([Picture]). // Convert the [SVG]'s internal representation to an Android-compatible one ([Picture]).
class SvgDrawableTranscoder : ResourceTranscoder<SVG, PictureDrawable> { class SvgDrawableTranscoder : ResourceTranscoder<SVG, PictureDrawable> {
@Nullable
override fun transcode( override fun transcode(
toTranscode: Resource<SVG>, toTranscode: Resource<SVG>,
options: Options, options: Options,
@ -102,6 +108,65 @@ class MyAppGlideModule : AppGlideModule() {
registry registry
.register(SVG::class.java, PictureDrawable::class.java, SvgDrawableTranscoder()) .register(SVG::class.java, PictureDrawable::class.java, SvgDrawableTranscoder())
.append(InputStream::class.java, SVG::class.java, SvgDecoder()) .append(InputStream::class.java, SVG::class.java, SvgDecoder())
///////
// Animated WebP
// // We should put our decoder before the build-in decoders,
// // because the Downsampler will consume arbitrary data and make the inputstream corrupt
// // on some devices
// val resources: Resources = context.resources
// val bitmapPool: BitmapPool = glide.bitmapPool
// val arrayPool: ArrayPool = glide.arrayPool
// /* static webp decoders */
// val webpDownsampler = WebpDownsampler(
// registry.imageHeaderParsers,
// resources.getDisplayMetrics(), bitmapPool, arrayPool
// )
// val bitmapDecoder = AnimatedWebpBitmapDecoder(arrayPool, bitmapPool)
// val byteBufferBitmapDecoder = ByteBufferBitmapWebpDecoder(webpDownsampler)
// val streamBitmapDecoder = StreamBitmapWebpDecoder(webpDownsampler, arrayPool)
// /* animate webp decoders */
// val byteBufferWebpDecoder = ByteBufferWebpDecoder(context, arrayPool, bitmapPool)
// registry /* Bitmaps for static webp images */
// .prepend(
// Registry.BUCKET_BITMAP,
// ByteBuffer::class.java,
// Bitmap::class.java, byteBufferBitmapDecoder
// )
// .prepend(
// Registry.BUCKET_BITMAP,
// InputStream::class.java,
// Bitmap::class.java, streamBitmapDecoder
// ) /* BitmapDrawables for static webp images */
// .prepend(
// Registry.BUCKET_BITMAP_DRAWABLE,
// ByteBuffer::class.java,
// BitmapDrawable::class.java,
// BitmapDrawableDecoder(resources, byteBufferBitmapDecoder)
// )
// .prepend(
// Registry.BUCKET_BITMAP_DRAWABLE,
// InputStream::class.java,
// BitmapDrawable::class.java,
// BitmapDrawableDecoder(resources, streamBitmapDecoder)
// ) /* Bitmaps for animated webp images*/
// .prepend(
// Registry.BUCKET_BITMAP,
// ByteBuffer::class.java,
// Bitmap::class.java, ByteBufferAnimatedBitmapDecoder(bitmapDecoder)
// )
// .prepend(
// Registry.BUCKET_BITMAP,
// InputStream::class.java,
// Bitmap::class.java, StreamAnimatedBitmapDecoder(bitmapDecoder)
// ) /* Animated webp images */
// .prepend(ByteBuffer::class.java, WebpDrawable::class.java, byteBufferWebpDecoder)
// .prepend(
// InputStream::class.java,
// WebpDrawable::class.java, StreamWebpDecoder(byteBufferWebpDecoder, arrayPool)
// )
// .prepend(WebpDrawable::class.java, WebpDrawableEncoder())
} }
override fun applyOptions(context: Context, builder: GlideBuilder) { override fun applyOptions(context: Context, builder: GlideBuilder) {

View File

@ -70,7 +70,7 @@ fun ActMain.accountAdd() {
val tootInstance = runApiTask2(apiHost) { TootInstance.getOrThrow(it) } val tootInstance = runApiTask2(apiHost) { TootInstance.getOrThrow(it) }
addPseudoAccount(apiHost, tootInstance)?.let { a -> addPseudoAccount(apiHost, tootInstance)?.let { a ->
showToast(false, R.string.server_confirmed) showToast(false, R.string.server_confirmed)
addColumn(defaultInsertPosition, a, ColumnType.LOCAL) addColumn(defaultInsertPosition, a, ColumnType.LOCAL,protect=true)
dialogHost.dismissSafe() dialogHost.dismissSafe()
} }
} }

View File

@ -383,7 +383,7 @@ fun ActMain.clickBoostBy(
columnType: ColumnType = ColumnType.BOOSTED_BY, columnType: ColumnType = ColumnType.BOOSTED_BY,
) { ) {
status ?: return status ?: return
addColumn(false, pos, accessInfo, columnType, status.id) addColumn(false, pos, accessInfo, columnType, params= arrayOf(status.id))
} }
fun ActMain.clickBoost(accessInfo: SavedAccount, status: TootStatus, willToast: Boolean) { fun ActMain.clickBoost(accessInfo: SavedAccount, status: TootStatus, willToast: Boolean) {

View File

@ -164,7 +164,7 @@ fun ActMain.conversationLocal(
else -> else ->
ColumnType.CONVERSATION ColumnType.CONVERSATION
}, },
statusId, params = arrayOf(statusId),
) )
private val reDetailedStatusTime = private val reDetailedStatusTime =
@ -501,7 +501,10 @@ fun ActMain.conversationFromTootsearch(
val replyId = status?.in_reply_to_id val replyId = status?.in_reply_to_id
when { when {
status == null -> showToast(true, result.error ?: "?") status == null -> showToast(true, result.error ?: "?")
replyId == null -> showToast(true, "showReplyTootsearch: in_reply_to_id is null") replyId == null -> showToast(
true,
"showReplyTootsearch: in_reply_to_id is null"
)
else -> conversationLocal(pos, a, replyId) else -> conversationLocal(pos, a, replyId)
} }
} }

View File

@ -28,8 +28,10 @@ import okhttp3.Request
fun ActMain.clickListTl(pos: Int, accessInfo: SavedAccount, item: TimelineItem?) { fun ActMain.clickListTl(pos: Int, accessInfo: SavedAccount, item: TimelineItem?) {
when (item) { when (item) {
is TootList -> addColumn(pos, accessInfo, ColumnType.LIST_TL, item.id) is TootList ->
is MisskeyAntenna -> addColumn(pos, accessInfo, ColumnType.MISSKEY_ANTENNA_TL, item.id) addColumn(pos, accessInfo, ColumnType.LIST_TL, params = arrayOf(item.id))
is MisskeyAntenna ->
addColumn(pos, accessInfo, ColumnType.MISSKEY_ANTENNA_TL, params = arrayOf(item.id))
} }
} }
@ -39,7 +41,7 @@ fun ActMain.clickListMoreButton(pos: Int, accessInfo: SavedAccount, item: Timeli
launchAndShowError { launchAndShowError {
actionsDialog(item.title) { actionsDialog(item.title) {
action(getString(R.string.list_timeline)) { action(getString(R.string.list_timeline)) {
addColumn(pos, accessInfo, ColumnType.LIST_TL, item.id) addColumn(pos, accessInfo, ColumnType.LIST_TL, params = arrayOf(item.id))
} }
action(getString(R.string.list_member)) { action(getString(R.string.list_member)) {
addColumn( addColumn(
@ -47,7 +49,7 @@ fun ActMain.clickListMoreButton(pos: Int, accessInfo: SavedAccount, item: Timeli
pos, pos,
accessInfo, accessInfo,
ColumnType.LIST_MEMBER, ColumnType.LIST_MEMBER,
item.id params = arrayOf(item.id)
) )
} }
action(getString(R.string.rename)) { action(getString(R.string.rename)) {

View File

@ -26,12 +26,7 @@ fun ActMain.clickNotificationFrom(
showToast(false, R.string.misskey_account_not_supported) showToast(false, R.string.misskey_account_not_supported)
} else { } else {
accessInfo.getFullAcct(who).validFull()?.let { accessInfo.getFullAcct(who).validFull()?.let {
addColumn( addColumn(pos, accessInfo, ColumnType.NOTIFICATION_FROM_ACCT, params = arrayOf(it))
pos,
accessInfo,
ColumnType.NOTIFICATION_FROM_ACCT,
it
)
} }
} }
} }

View File

@ -59,7 +59,7 @@ private fun ActMain.serverProfileDirectory(
pos, pos,
accessInfo, accessInfo,
ColumnType.PROFILE_DIRECTORY, ColumnType.PROFILE_DIRECTORY,
host params = arrayOf(host)
) )
// 疑似アカウントで開く // 疑似アカウントで開く
@ -70,7 +70,7 @@ private fun ActMain.serverProfileDirectory(
pos, pos,
ai, ai,
ColumnType.PROFILE_DIRECTORY, ColumnType.PROFILE_DIRECTORY,
host params = arrayOf(host)
) )
} }
} }
@ -116,7 +116,7 @@ fun ActMain.serverInformation(
pos, pos,
SavedAccount.na, SavedAccount.na,
ColumnType.INSTANCE_INFORMATION, ColumnType.INSTANCE_INFORMATION,
host params = arrayOf(host),
) )
// ドメインブロック一覧から解除 // ドメインブロック一覧から解除

View File

@ -607,5 +607,5 @@ fun ActMain.openStatusHistory(
accessInfo: SavedAccount, accessInfo: SavedAccount,
status: TootStatus, status: TootStatus,
) { ) {
addColumn(pos, accessInfo, ColumnType.STATUS_HISTORY, status.id, status.json) addColumn(pos, accessInfo, ColumnType.STATUS_HISTORY, params = arrayOf(status.id, status.json))
} }

View File

@ -145,14 +145,13 @@ fun ActMain.tagTimeline(
acctAscii: String? = null, acctAscii: String? = null,
) { ) {
if (acctAscii == null) { if (acctAscii == null) {
addColumn(pos, accessInfo, ColumnType.HASHTAG, tagWithoutSharp) addColumn(pos, accessInfo, ColumnType.HASHTAG, params = arrayOf(tagWithoutSharp))
} else { } else {
addColumn( addColumn(
pos, pos,
accessInfo, accessInfo,
ColumnType.HASHTAG_FROM_ACCT, ColumnType.HASHTAG_FROM_ACCT,
tagWithoutSharp, params = arrayOf(tagWithoutSharp, acctAscii)
acctAscii
) )
} }
} }

View File

@ -38,12 +38,14 @@ fun ActMain.timeline(
)?.let { account -> )?.let { account ->
when (type) { when (type) {
ColumnType.PROFILE -> ColumnType.PROFILE ->
account.loginAccount?.id?.let { addColumn(pos, account, type, it) } account.loginAccount?.id?.let {
addColumn(pos, account, type, params = arrayOf(it))
}
ColumnType.PROFILE_DIRECTORY -> ColumnType.PROFILE_DIRECTORY ->
addColumn(pos, account, type, account.apiHost) addColumn(pos, account, type, params = arrayOf(account.apiHost))
else -> addColumn(pos, account, type, *args) else -> addColumn(pos, account, type, params = args)
} }
} }
} }
@ -95,7 +97,7 @@ fun ActMain.timelineDomain(
pos: Int, pos: Int,
accessInfo: SavedAccount, accessInfo: SavedAccount,
host: Host, host: Host,
) = addColumn(pos, accessInfo, ColumnType.DOMAIN_TIMELINE, host) ) = addColumn(pos, accessInfo, ColumnType.DOMAIN_TIMELINE, params = arrayOf(host))
// 指定タンスのローカルタイムラインを開く // 指定タンスのローカルタイムラインを開く
fun ActMain.timelineLocal( fun ActMain.timelineLocal(
@ -131,7 +133,7 @@ private fun ActMain.timelineAround(
pos: Int, pos: Int,
id: EntityId, id: EntityId,
type: ColumnType, type: ColumnType,
) = addColumn(pos, accessInfo, type, id) ) = addColumn(pos, accessInfo, type, params = arrayOf(id))
// 投稿を同期してstatusIdを調べてから指定アカウントでタイムラインを開く // 投稿を同期してstatusIdを調べてから指定アカウントでタイムラインを開く
private fun ActMain.timelineAroundByStatus( private fun ActMain.timelineAroundByStatus(

View File

@ -573,7 +573,7 @@ private fun ActMain.userProfileFromUrlOrAcct(
openCustomTab(whoUrl) openCustomTab(whoUrl)
} }
else -> addColumn(pos, accessInfo, ColumnType.PROFILE, who.id) else -> addColumn(pos, accessInfo, ColumnType.PROFILE, params = arrayOf(who.id))
} }
} }
} }
@ -597,7 +597,7 @@ fun ActMain.userProfileFromAnotherAccount(
accountListArg = accountListNonPseudo(who.apDomain) accountListArg = accountListNonPseudo(who.apDomain)
)?.let { ai -> )?.let { ai ->
if (ai.matchHost(accessInfo)) { if (ai.matchHost(accessInfo)) {
addColumn(pos, ai, ColumnType.PROFILE, who.id) addColumn(pos, ai, ColumnType.PROFILE, params = arrayOf(who.id))
} else { } else {
userProfileFromUrlOrAcct(pos, ai, accessInfo.getFullAcct(who), who.url) userProfileFromUrlOrAcct(pos, ai, accessInfo.getFullAcct(who), who.url)
} }
@ -613,7 +613,7 @@ fun ActMain.userProfileLocal(
) { ) {
when { when {
accessInfo.isNA -> userProfileFromAnotherAccount(pos, accessInfo, who) accessInfo.isNA -> userProfileFromAnotherAccount(pos, accessInfo, who)
else -> addColumn(pos, accessInfo, ColumnType.PROFILE, who.id) else -> addColumn(pos, accessInfo, ColumnType.PROFILE, params = arrayOf(who.id))
} }
} }

View File

@ -77,7 +77,8 @@ fun ActMain.addColumn(
indexArg: Int, indexArg: Int,
ai: SavedAccount, ai: SavedAccount,
type: ColumnType, type: ColumnType,
vararg params: Any, protect: Boolean = false,
params: Array<out Any> = emptyArray(),
): Column { ): Column {
if (!allowColumnDuplication) { if (!allowColumnDuplication) {
// 既に同じカラムがあればそこに移動する // 既に同じカラムがあればそこに移動する
@ -90,7 +91,8 @@ fun ActMain.addColumn(
} }
// //
val col = Column(appState, ai, type.id, *params) val col = Column(appState, ai, type.id, params)
if (protect) col.dontClose = true
val index = addColumn(col, indexArg) val index = addColumn(col, indexArg)
scrollAndLoad(index) scrollAndLoad(index)
return col return col
@ -100,16 +102,16 @@ fun ActMain.addColumn(
indexArg: Int, indexArg: Int,
ai: SavedAccount, ai: SavedAccount,
type: ColumnType, type: ColumnType,
vararg params: Any, protect: Boolean = false,
): Column { params: Array<out Any> = emptyArray(),
return addColumn( ): Column = addColumn(
PrefB.bpAllowColumnDuplication.value, PrefB.bpAllowColumnDuplication.value,
indexArg, indexArg,
ai, ai,
type, type,
*params protect = protect,
) params = params,
} )
fun ActMain.removeColumn(column: Column) { fun ActMain.removeColumn(column: Column) {
val idxColumn = appState.columnIndex(column) ?: return val idxColumn = appState.columnIndex(column) ?: return
@ -342,7 +344,7 @@ fun ActMain.searchFromActivityResult(data: Intent?, columnType: ColumnType) =
defaultInsertPosition, defaultInsertPosition,
SavedAccount.na, SavedAccount.na,
columnType, columnType,
it params = arrayOf(it)
) )
} }

View File

@ -320,11 +320,11 @@ private fun ActMain.afterAccountAdd(
} }
// 適当にカラムを追加する // 適当にカラムを追加する
addColumn(false, defaultInsertPosition, account, ColumnType.HOME) addColumn(false, defaultInsertPosition, account, ColumnType.HOME, protect = true)
if (daoSavedAccount.isSingleAccount()) { if (daoSavedAccount.isSingleAccount()) {
addColumn(false, defaultInsertPosition, account, ColumnType.NOTIFICATIONS) addColumn(false, defaultInsertPosition, account, ColumnType.NOTIFICATIONS, protect = true)
addColumn(false, defaultInsertPosition, account, ColumnType.LOCAL) addColumn(false, defaultInsertPosition, account, ColumnType.LOCAL, protect = true)
addColumn(false, defaultInsertPosition, account, ColumnType.FEDERATE) addColumn(false, defaultInsertPosition, account, ColumnType.FEDERATE, protect = true)
} }
// 通知の更新が必要かもしれない // 通知の更新が必要かもしれない

View File

@ -26,7 +26,10 @@ import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.dialog.pickAccount import jp.juggler.subwaytooter.dialog.pickAccount
import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.pref.PrefDevice.Companion.PUSH_DISTRIBUTOR_NONE
import jp.juggler.subwaytooter.pref.PrefS import jp.juggler.subwaytooter.pref.PrefS
import jp.juggler.subwaytooter.pref.prefDevice
import jp.juggler.subwaytooter.push.fcmHandler
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.accountListCanSeeMyReactions import jp.juggler.subwaytooter.table.accountListCanSeeMyReactions
import jp.juggler.subwaytooter.util.VersionString import jp.juggler.subwaytooter.util.VersionString
@ -404,7 +407,12 @@ class SideMenuAdapter(
// }, // },
Item(icon = R.drawable.ic_search, title = R.string.notestock) { Item(icon = R.drawable.ic_search, title = R.string.notestock) {
addColumn(defaultInsertPosition, SavedAccount.na, ColumnType.SEARCH_NOTESTOCK, "") addColumn(
defaultInsertPosition,
SavedAccount.na,
ColumnType.SEARCH_NOTESTOCK,
params = arrayOf("")
)
}, },
Item(), Item(),
@ -524,7 +532,8 @@ class SideMenuAdapter(
ItemType.IT_NOTIFICATION_PERMISSION -> ItemType.IT_NOTIFICATION_PERMISSION ->
viewOrInflate<TextView>(view, parent, R.layout.lv_sidemenu_item).apply { viewOrInflate<TextView>(view, parent, R.layout.lv_sidemenu_item).apply {
isAllCaps = false isAllCaps = false
text = actMain.getString(R.string.notification_permission_not_granted) val action = notificationActionRecommend() ?: return@apply
text = actMain.getString(action.first)
val drawable = createColoredDrawable(actMain, icon, iconColor, 1f) val drawable = createColoredDrawable(actMain, icon, iconColor, 1f)
setCompoundDrawablesRelativeWithIntrinsicBounds( setCompoundDrawablesRelativeWithIntrinsicBounds(
drawable, drawable,
@ -534,11 +543,8 @@ class SideMenuAdapter(
) )
setOnClickListener { setOnClickListener {
drawer.closeDrawer(GravityCompat.START) drawer.closeDrawer(GravityCompat.START)
if (actMain.prNotification.spec.listNotGranded(actMain).isNotEmpty()) { notificationActionRecommend()?.second?.invoke()
actMain.prNotification.openAppSetting(actMain) filterListItems()
} else {
filterListItems()
}
} }
} }
} }
@ -581,11 +587,24 @@ class SideMenuAdapter(
this.notifyDataSetChanged() this.notifyDataSetChanged()
} }
private fun notificationActionRecommend(): Pair<Int, () -> Unit>? = when {
actMain.prNotification.spec.listNotGranded(actMain).isNotEmpty() ->
Pair(R.string.notification_permission_not_granted) {
actMain.prNotification.openAppSetting(actMain)
}
(actMain.prefDevice.pushDistributor.isNullOrEmpty() && actMain.fcmHandler.noFcm) ||
actMain.prefDevice.pushDistributor == PUSH_DISTRIBUTOR_NONE ->
Pair(R.string.notification_push_distributor_disabled) {
actMain.selectPushDistributor()
}
else -> null
}
fun filterListItems() { fun filterListItems() {
list = originalList.filter { list = originalList.filter {
when (it.itemType) { when (it.itemType) {
ItemType.IT_NOTIFICATION_PERMISSION -> ItemType.IT_NOTIFICATION_PERMISSION ->
actMain.prNotification.spec.listNotGranded(actMain).isNotEmpty() notificationActionRecommend() != null
else -> true else -> true
} }
} }

View File

@ -6,6 +6,7 @@ import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.auth.AuthBase import jp.juggler.subwaytooter.api.auth.AuthBase
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRefOrNull
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.* import jp.juggler.subwaytooter.util.*
import jp.juggler.util.data.* import jp.juggler.util.data.*
@ -603,7 +604,7 @@ suspend fun TootApiClient.syncAccountByUrl(
}.toPostRequestBuilder() }.toPostRequestBuilder()
) )
?.apply { ?.apply {
ar = TootAccountRef.mayNull(parser, parser.account(jsonObject)) ar = tootAccountRefOrNull(parser, parser.account(jsonObject))
if (ar == null && error == null) { if (ar == null && error == null) {
setError(context.getString(R.string.user_id_conversion_failed)) setError(context.getString(R.string.user_id_conversion_failed))
} }
@ -646,7 +647,7 @@ suspend fun TootApiClient.syncAccountByAcct(
.toPostRequestBuilder() .toPostRequestBuilder()
) )
?.apply { ?.apply {
ar = TootAccountRef.mayNull(parser, parser.account(jsonObject)) ar = tootAccountRefOrNull(parser, parser.account(jsonObject))
if (ar == null && error == null) { if (ar == null && error == null) {
setError(context.getString(R.string.user_id_conversion_failed)) setError(context.getString(R.string.user_id_conversion_failed))
} }

View File

@ -1,6 +1,7 @@
package jp.juggler.subwaytooter.api.auth package jp.juggler.subwaytooter.api.auth
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase
import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.api.entity.EntityId
@ -8,24 +9,26 @@ import jp.juggler.subwaytooter.notification.checkNotificationImmediate
import jp.juggler.subwaytooter.notification.checkNotificationImmediateAll import jp.juggler.subwaytooter.notification.checkNotificationImmediateAll
import jp.juggler.subwaytooter.pref.PrefL import jp.juggler.subwaytooter.pref.PrefL
import jp.juggler.subwaytooter.pref.lazyContext import jp.juggler.subwaytooter.pref.lazyContext
import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.*
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.appDatabase
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
val Context.authRepo val Context.authRepo
get() = AuthRepo( get() = AuthRepo(
context = this, context = this,
daoAcctColor = AcctColor.Access(appDatabase), database = appDatabase,
daoSavedAccount = SavedAccount.Access(appDatabase, lazyContext),
) )
class AuthRepo( class AuthRepo(
private val context: Context = lazyContext, private val context: Context = lazyContext,
private val database: SQLiteDatabase = appDatabase,
private val daoAcctColor: AcctColor.Access = private val daoAcctColor: AcctColor.Access =
AcctColor.Access(appDatabase), AcctColor.Access(database),
private val daoSavedAccount: SavedAccount.Access = private val daoSavedAccount: SavedAccount.Access =
SavedAccount.Access(appDatabase, lazyContext), SavedAccount.Access(database, context),
private val daoPushMessage: PushMessage.Access =
PushMessage.Access(database),
private val daoNotificationShown: NotificationShown.Access =
NotificationShown.Access(database),
) { ) {
companion object { companion object {
private val log = LogCategory("AuthRepo") private val log = LogCategory("AuthRepo")
@ -69,6 +72,8 @@ class AuthRepo(
PrefL.lpTabletTootDefaultAccount.value = -1L PrefL.lpTabletTootDefaultAccount.value = -1L
} }
daoSavedAccount.delete(account.db_id) daoSavedAccount.delete(account.db_id)
daoPushMessage.deleteAccount(account.acct)
daoNotificationShown.cleayByAcct(account.acct)
// appServerUnregister(context.applicationContextSafe, account) // appServerUnregister(context.applicationContextSafe, account)
} }

View File

@ -38,7 +38,6 @@ class APTag(parser: TootParser, jsonArray: JsonArray?) {
) )
} else { } else {
emojiList[shortcode] = CustomEmoji( emojiList[shortcode] = CustomEmoji(
apDomain = parser.apDomain,
shortcode = shortcode, shortcode = shortcode,
url = iconUrl, url = iconUrl,
staticUrl = iconUrl, staticUrl = iconUrl,

View File

@ -4,11 +4,15 @@ import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.util.data.JsonObject import jp.juggler.util.data.JsonObject
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
class MisskeyNoteUpdate(apDomain: Host, apiHost: Host, src: JsonObject) { class MisskeyNoteUpdate(
companion object { val noteId: EntityId,
private val log = LogCategory("MisskeyNoteUpdate") val type: Type,
} var reaction: String? = null,
var userId: EntityId? = null,
var deletedAt: Long? = null,
var choice: Int? = null,
var emoji: CustomEmoji? = null,
) {
enum class Type { enum class Type {
REACTION, REACTION,
UNREACTION, UNREACTION,
@ -16,53 +20,34 @@ class MisskeyNoteUpdate(apDomain: Host, apiHost: Host, src: JsonObject) {
VOTED VOTED
} }
val noteId: EntityId companion object {
val type: Type
var reaction: String? = null
var userId: EntityId? = null
var deletedAt: Long? = null
var choice: Int? = null
var emoji: CustomEmoji? = null
init { fun misskeyNoteUpdate(src: JsonObject): MisskeyNoteUpdate {
noteId = EntityId.mayNull(src.string("id")) ?: error("MisskeyNoteUpdate: missing note id")
// root.body.body val noteId = EntityId.mayNull(src.string("id"))
val body2 = src.jsonObject("body") ?: error("MisskeyNoteUpdate: missing body") ?: error("MisskeyNoteUpdate: missing note id")
when (val strType = src.string("type")) { // root.body.body
"reacted" -> { val body2 = src.jsonObject("body")
type = Type.REACTION ?: error("MisskeyNoteUpdate: missing body")
reaction = body2.string("reaction")
userId = EntityId.mayDefault(body2.string("userId")) val type: Type = when (val strType = src.string("type")) {
emoji = body2.jsonObject("emoji")?.let { "reacted" -> Type.REACTION
try { "unreacted" -> Type.UNREACTION
CustomEmoji.decodeMisskey(apDomain, apiHost, it) "deleted" -> Type.DELETED
} catch (ex: Throwable) { "pollVoted" -> Type.VOTED
log.e(ex, "can't parse custom emoji.") else -> error("MisskeyNoteUpdate: unknown type $strType")
null
}
}
} }
"unreacted" -> { return MisskeyNoteUpdate(
type = Type.UNREACTION noteId = noteId,
reaction = body2.string("reaction") type = type,
userId = EntityId.mayDefault(body2.string("userId")) reaction = body2.string("reaction"),
} userId = EntityId.mayNull(body2.string("userId")),
deletedAt = body2.string("deletedAt")?.let { TootStatus.parseTime(it) },
"deleted" -> { choice = body2.int("choice"),
type = Type.DELETED emoji = parseItem(body2.jsonObject("emoji"), CustomEmoji::decodeMisskey),
deletedAt = TootStatus.parseTime(body2.string("deletedAt")) )
}
"pollVoted" -> {
type = Type.VOTED
choice = body2.int("choice")
userId = EntityId.mayDefault(body2.string("userId"))
}
else -> error("MisskeyNoteUpdate: unknown type $strType")
} }
} }
} }

View File

@ -7,6 +7,7 @@ import android.widget.TextView
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.MisskeyAccountDetailMap import jp.juggler.subwaytooter.api.MisskeyAccountDetailMap
import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRefOrNull
import jp.juggler.subwaytooter.api.entity.TootStatus.Companion.tootStatus import jp.juggler.subwaytooter.api.entity.TootStatus.Companion.tootStatus
import jp.juggler.subwaytooter.emoji.CustomEmoji import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefB
@ -386,6 +387,8 @@ open class TootAccount(
"""\Ahttps://($reHostIdn)/users/(\w|\w+[\w-]*\w)(?=\z|[?#])""" """\Ahttps://($reHostIdn)/users/(\w|\w+[\w-]*\w)(?=\z|[?#])"""
.asciiPattern() .asciiPattern()
private val reMisskeyIoProxy = """\Ahttps://misskey\.io/proxy/""".toRegex()
fun tootAccount(parser: TootParser, src: JsonObject): TootAccount { fun tootAccount(parser: TootParser, src: JsonObject): TootAccount {
src["_fromStream"] = parser.fromStream src["_fromStream"] = parser.fromStream
@ -427,9 +430,7 @@ open class TootAccount(
ServiceType.MISSKEY -> { ServiceType.MISSKEY -> {
custom_emojis = custom_emojis =
parseMapOrNull(src.jsonArray("emojis")) { parseMapOrNull(src.jsonArray("emojis"), CustomEmoji::decodeMisskey)
CustomEmoji.decodeMisskey(parser.apDomain, parser.apiHost, it)
}
profile_emojis = null profile_emojis = null
username = src.stringOrThrow("username") username = src.stringOrThrow("username")
@ -476,17 +477,14 @@ open class TootAccount(
created_at = src.string("createdAt") created_at = src.string("createdAt")
time_created_at = TootStatus.parseTime(created_at) time_created_at = TootStatus.parseTime(created_at)
// https://github.com/syuilo/misskey/blob/develop/src/client/scripts/get-static-image-url.ts // 画像を静止させるURLはAPIとしては提供されていない
fun String.getStaticImageUrl(): String? { // サーバ側で実装されている方法は仕様が安定しない
val uri = this.mayUri() ?: return null // クライアント側でアニメーションを止めるのが正解らしいが、
val dummy = "${uri.encodedAuthority}${uri.encodedPath}" // 対応できてないな…
return "https://${parser.linkHelper.apiHost.ascii}/proxy/$dummy?url=${encodePercent()}&static=1"
}
avatar = src.string("avatarUrl") avatar = src.string("avatarUrl")
avatar_static = src.string("avatarUrl")?.getStaticImageUrl() avatar_static = src.string("avatarUrl")
header = src.string("bannerUrl") header = src.string("bannerUrl")
header_static = src.string("bannerUrl")?.getStaticImageUrl() header_static = src.string("bannerUrl")
pinnedNoteIds = src.stringArrayList("pinnedNoteIds") pinnedNoteIds = src.stringArrayList("pinnedNoteIds")
if (parser.misskeyDecodeProfilePin) { if (parser.misskeyDecodeProfilePin) {
@ -561,13 +559,8 @@ open class TootAccount(
else -> { else -> {
// 絵文字データは先に読んでおく // 絵文字データは先に読んでおく
custom_emojis = parseMapOrNull(src.jsonArray("emojis")) { custom_emojis =
CustomEmoji.decode( parseMapOrNull(src.jsonArray("emojis"), CustomEmoji::decodeMastodon)
parser.apDomain,
parser.apiHost,
it
)
}
profile_emojis = when (val o = src["profile_emojis"]) { profile_emojis = when (val o = src["profile_emojis"]) {
is JsonArray -> parseMapOrNull(o) { NicoProfileEmoji(it) } is JsonArray -> parseMapOrNull(o) { NicoProfileEmoji(it) }
@ -587,7 +580,7 @@ open class TootAccount(
note = src.string("note") note = src.string("note")
source = parseSource(src.jsonObject("source")) source = parseSource(src.jsonObject("source"))
movedRef = TootAccountRef.mayNull( movedRef = tootAccountRefOrNull(
parser, parser,
src.jsonObject("moved")?.let { src.jsonObject("moved")?.let {
tootAccount(parser, it) tootAccount(parser, it)
@ -692,8 +685,8 @@ open class TootAccount(
acct = acct, acct = acct,
apDomain = apDomain, apDomain = apDomain,
apiHost = apiHost, apiHost = apiHost,
avatar = avatar, avatar = avatar?.replace(reMisskeyIoProxy, "https://"),
avatar_static = avatar_static, avatar_static = avatar_static?.replace(reMisskeyIoProxy, "https://"),
birthday = birthday, birthday = birthday,
bot = bot, bot = bot,
created_at = created_at, created_at = created_at,

View File

@ -18,10 +18,7 @@ class TootAccountRef private constructor(
fun get() = TootAccountMap.find(this) fun get() = TootAccountMap.find(this)
companion object { companion object {
fun notNull(parser: TootParser, account: TootAccount) = fun tootAccountRefOrNull(parser: TootParser, account: TootAccount?): TootAccountRef? {
tootAccountRef(parser, account)
fun mayNull(parser: TootParser, account: TootAccount?): TootAccountRef? {
return when (account) { return when (account) {
null -> null null -> null
else -> tootAccountRef(parser, account) else -> tootAccountRef(parser, account)

View File

@ -38,9 +38,7 @@ class TootAnnouncement(
private val log = LogCategory("TootAnnouncement") private val log = LogCategory("TootAnnouncement")
fun tootAnnouncement(parser: TootParser, src: JsonObject): TootAnnouncement { fun tootAnnouncement(parser: TootParser, src: JsonObject): TootAnnouncement {
val custom_emojis = parseMapOrNull(src.jsonArray("emojis")) { val custom_emojis = parseMapOrNull(src.jsonArray("emojis"), CustomEmoji::decodeMastodon)
CustomEmoji.decode(parser.apDomain, parser.apiHost, it)
}
val reactions = parseListOrNull(src.jsonArray("reactions")) { val reactions = parseListOrNull(src.jsonArray("reactions")) {
TootReaction.parseFedibird(it) TootReaction.parseFedibird(it)
} }

View File

@ -3,6 +3,7 @@ package jp.juggler.subwaytooter.api.entity
import android.content.Context import android.content.Context
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRefOrNull
import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.util.data.JsonObject import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.notEmpty import jp.juggler.util.data.notEmpty
@ -81,90 +82,79 @@ class TootNotification(
const val TYPE_STATUS_REFERENCE = "status_reference" const val TYPE_STATUS_REFERENCE = "status_reference"
const val TYPE_SCHEDULED_STATUS = "scheduled_status" const val TYPE_SCHEDULED_STATUS = "scheduled_status"
fun tootNotification(parser: TootParser, src: JsonObject): TootNotification { private fun tootNotificationMisskey(parser: TootParser, src: JsonObject): TootNotification {
val id: EntityId // Misskeyの通知APIはページネーションをIDでしか行えない
// One of: "mention", "reblog", "favourite", "follow" // これは改善される予定 https://github.com/syuilo/misskey/issues/2275
val type: String
// The Account sending the notification to the user
val accountRef: TootAccountRef?
// The Status associated with the notification, if applicable val created_at: String? = src.string("createdAt")
// 投稿の更新により変更可能になる
val status: TootStatus?
val reaction: TootReaction? val accountRef = tootAccountRefOrNull(
parser,
parser.account(src.jsonObject("user"))
)
val reblog_visibility: TootVisibility val reaction: TootReaction? = src.string("reaction")
?.notEmpty()
?.let { TootReaction.parseMisskey(it) }
// The time the notification was created
val created_at: String?
val time_created_at: Long
if (parser.serviceType == ServiceType.MISSKEY) {
id = EntityId.mayDefault(src.string("id"))
type = src.stringOrThrow("type")
created_at = src.string("createdAt")
time_created_at = TootStatus.parseTime(created_at)
accountRef = TootAccountRef.mayNull(
parser,
parser.account(
src.jsonObject("user")
)
)
status = parser.status(
src.jsonObject("note")
)
reaction = src.string("reaction")
?.notEmpty()
?.let { TootReaction.parseMisskey(it) }
reblog_visibility = TootVisibility.Unknown
// Misskeyの通知APIはページネーションをIDでしか行えない
// これは改善される予定 https://github.com/syuilo/misskey/issues/2275
} else {
id = EntityId.mayDefault(src.string("id"))
type = src.stringOrThrow("type")
created_at = src.string("created_at")
time_created_at = TootStatus.parseTime(created_at)
accountRef =
TootAccountRef.mayNull(parser, parser.account(src.jsonObject("account")))
status = parser.status(src.jsonObject("status"))
reaction = src.jsonObject("emoji_reaction")
?.notEmpty()
?.let { TootReaction.parseFedibird(it) }
// pleroma unicode emoji
?: src.string("emoji")?.let { TootReaction(name = it) }
// fedibird
// https://github.com/fedibird/mastodon/blob/7974fd3c7ec11ea9f7bef4ad7f4009fff53f62af/app/serializers/rest/notification_serializer.rb#L9
val visibilityString = when {
src.boolean("limited") == true -> "limited"
else -> src.string("reblog_visibility")
}
reblog_visibility = TootVisibility.parseMastodon(visibilityString)
?: TootVisibility.Unknown
}
return TootNotification( return TootNotification(
json = src, json = src,
id = id, id = EntityId.mayDefault(src.string("id")),
type = type, type = src.stringOrThrow("type"),
accountRef = accountRef,
status = parser.status(src.jsonObject("note")),
reaction = reaction,
reblog_visibility = TootVisibility.Unknown,
created_at = created_at,
time_created_at = TootStatus.parseTime(created_at),
)
}
private fun tootNotificationMastodon(
parser: TootParser,
src: JsonObject,
): TootNotification {
val created_at: String? = src.string("created_at")
val accountRef: TootAccountRef? =
tootAccountRefOrNull(parser, parser.account(src.jsonObject("account")))
val status: TootStatus? = parser.status(src.jsonObject("status"))
val reaction: TootReaction? = src.jsonObject("emoji_reaction")
?.notEmpty()
?.let { TootReaction.parseFedibird(it) }
?: src.string("emoji")?.let { TootReaction(name = it) } // pleroma unicode emoji
// fedibird
// https://github.com/fedibird/mastodon/blob/7974fd3c7ec11ea9f7bef4ad7f4009fff53f62af/app/serializers/rest/notification_serializer.rb#L9
val visibilityString = when {
src.boolean("limited") == true -> "limited"
else -> src.string("reblog_visibility")
}
val reblog_visibility = TootVisibility.parseMastodon(visibilityString)
?: TootVisibility.Unknown
return TootNotification(
json = src,
id = EntityId.mayDefault(src.string("id")),
type = src.stringOrThrow("type"),
accountRef = accountRef, accountRef = accountRef,
status = status, status = status,
reaction = reaction, reaction = reaction,
reblog_visibility = reblog_visibility, reblog_visibility = reblog_visibility,
created_at = created_at, created_at = created_at,
time_created_at = time_created_at, time_created_at = TootStatus.parseTime(created_at),
) )
} }
fun tootNotification(parser: TootParser, src: JsonObject): TootNotification =
when (parser.serviceType) {
ServiceType.MISSKEY -> tootNotificationMisskey(parser, src)
else -> tootNotificationMastodon(parser, src)
}
} }
override fun getOrderId() = id override fun getOrderId() = id
@ -172,16 +162,8 @@ class TootNotification(
fun getNotificationLine(context: Context): String { fun getNotificationLine(context: Context): String {
val name = when (PrefB.bpShowAcctInSystemNotification.value) { val name = when (PrefB.bpShowAcctInSystemNotification.value) {
false -> accountRef?.decoded_display_name true -> accountRef?.get()?.acct?.pretty?.notEmpty()?.let { "@$it" }
else -> accountRef?.decoded_display_name
true -> {
val acctPretty = accountRef?.get()?.acct?.pretty
if (acctPretty?.isNotEmpty() == true) {
"@$acctPretty"
} else {
null
}
}
} ?: "?" } ?: "?"
return when (type) { return when (type) {
@ -223,7 +205,10 @@ class TootNotification(
TYPE_EMOJI_REACTION_PLEROMA, TYPE_EMOJI_REACTION_PLEROMA,
TYPE_EMOJI_REACTION, TYPE_EMOJI_REACTION,
TYPE_REACTION, TYPE_REACTION,
-> context.getString(R.string.display_name_reaction_by, name) -> arrayOf(
context.getString(R.string.display_name_reaction_by, name),
reaction?.name
).mapNotNull { it.notEmpty() }.joinToString(" ")
TYPE_VOTE, TYPE_VOTE,
TYPE_POLL_VOTE_MISSKEY, TYPE_POLL_VOTE_MISSKEY,
@ -239,7 +224,7 @@ class TootNotification(
TYPE_POLL, TYPE_POLL,
-> context.getString(R.string.end_of_polling_from, name) -> context.getString(R.string.end_of_polling_from, name)
else -> "?" else -> context.getString(R.string.unknown_notification_from, name) + " :" + type
} }
} }
} }

View File

@ -9,6 +9,7 @@ import androidx.annotation.StringRes
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootAccountMap import jp.juggler.subwaytooter.api.TootAccountMap
import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRef
import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment
import jp.juggler.subwaytooter.emoji.CustomEmoji import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.subwaytooter.mfm.SpannableStringBuilderEx import jp.juggler.subwaytooter.mfm.SpannableStringBuilderEx
@ -113,9 +114,9 @@ class TootStatus(
//Application from which the status was posted //Application from which the status was posted
val application: TootApplication?, val application: TootApplication?,
var custom_emojis: HashMap<String, CustomEmoji>? = null, var custom_emojis: MutableMap<String, CustomEmoji>?,
val profile_emojis: HashMap<String, NicoProfileEmoji>?, val profile_emojis: Map<String, NicoProfileEmoji>?,
// The time the status was created // The time the status was created
private val created_at: String?, private val created_at: String?,
@ -569,12 +570,19 @@ class TootStatus(
} }
val who = parser.account(src.jsonObject("user")) val who = parser.account(src.jsonObject("user"))
?: error("missing account") ?: error("missing account")
val accountRef = TootAccountRef.tootAccountRef(parser, who) val accountRef = tootAccountRef(parser, who)
val account = accountRef.get() val account = accountRef.get()
val created_at = src.string("createdAt") val created_at = src.string("createdAt")
// 絵文字マップはすぐ後で使うので、最初の方で読んでおく // 絵文字マップはすぐ後で使うので、最初の方で読んでおく
val custom_emojis = parseMapOrNull(src.jsonArray("emojis")) { var custom_emojis: MutableMap<String, CustomEmoji>? =
CustomEmoji.decodeMisskey(parser.apDomain, parser.apiHost, it) parseMapOrNull(src.jsonArray("emojis"),CustomEmoji::decodeMisskey)
val reactionEmojis: MutableMap<String, CustomEmoji>? =
CustomEmoji.decodeMisskey12ReactionEmojis(src.jsonObject("reactionEmojis"))
if (reactionEmojis != null) {
custom_emojis = when (custom_emojis) {
null -> reactionEmojis
else -> (reactionEmojis + custom_emojis).toMutableMap()
}
} }
// Misskeyは画像毎にNSFWフラグがある。どれか枚でもNSFWならトゥート全体がNSFWということにする // Misskeyは画像毎にNSFWフラグがある。どれか枚でもNSFWならトゥート全体がNSFWということにする
@ -941,9 +949,7 @@ class TootStatus(
val created_at = src.string("created_at") val created_at = src.string("created_at")
// 絵文字マップはすぐ後で使うので、最初の方で読んでおく // 絵文字マップはすぐ後で使うので、最初の方で読んでおく
val custom_emojis = parseMapOrNull(src.jsonArray("emojis")) { val custom_emojis = parseMapOrNull(src.jsonArray("emojis"),CustomEmoji::decodeMastodon)
CustomEmoji.decode(parser.apDomain, parser.apiHost, it)
}
val profile_emojis = when (val o = src["profile_emojis"]) { val profile_emojis = when (val o = src["profile_emojis"]) {
is JsonArray -> parseMapOrNull(o) { NicoProfileEmoji(it) } is JsonArray -> parseMapOrNull(o) { NicoProfileEmoji(it) }
@ -981,7 +987,7 @@ class TootStatus(
time_created_at = parseTime(created_at) time_created_at = parseTime(created_at)
media_attachments = media_attachments =
parseListOrNull(src.jsonArray("media_attachments")) { parseListOrNull(src.jsonArray("media_attachments")) {
tootAttachment(parser,it) tootAttachment(parser, it)
} }
val visibilityString = when { val visibilityString = when {
src.boolean("limited") == true -> "limited" src.boolean("limited") == true -> "limited"

View File

@ -2,6 +2,7 @@ package jp.juggler.subwaytooter.api.finder
import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRefOrNull
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.data.JsonArray import jp.juggler.util.data.JsonArray
import jp.juggler.util.data.JsonObject import jp.juggler.util.data.JsonObject
@ -23,7 +24,7 @@ private fun misskeyUnwrapRelationAccount(parser: TootParser, srcList: JsonArray,
srcList.objectList().mapNotNull { srcList.objectList().mapNotNull {
when (val relationId = EntityId.mayNull(it.string("id"))) { when (val relationId = EntityId.mayNull(it.string("id"))) {
null -> null null -> null
else -> TootAccountRef.mayNull(parser, parser.account(it.jsonObject(key))) else -> tootAccountRefOrNull(parser, parser.account(it.jsonObject(key)))
?.apply { _orderId = relationId } ?.apply { _orderId = relationId }
} }
} }

View File

@ -327,7 +327,7 @@ class Column(
appState: AppState, appState: AppState,
accessInfo: SavedAccount, accessInfo: SavedAccount,
type: Int, type: Int,
vararg params: Any, params: Array<out Any>,
) : this( ) : this(
appState = appState, appState = appState,
context = appState.context, context = appState.context,

View File

@ -8,6 +8,7 @@ import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRefOrNull
import jp.juggler.subwaytooter.columnviewholder.saveScrollPosition import jp.juggler.subwaytooter.columnviewholder.saveScrollPosition
import jp.juggler.util.data.* import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
@ -122,7 +123,7 @@ suspend fun Column.loadProfileAccount(
// ユーザリレーションの取り扱いのため、別のparserを作ってはいけない // ユーザリレーションの取り扱いのため、別のparserを作ってはいけない
parser.misskeyDecodeProfilePin = true parser.misskeyDecodeProfilePin = true
try { try {
TootAccountRef.mayNull(parser, parser.account(result1.jsonObject))?.also { a -> tootAccountRefOrNull(parser, parser.account(result1.jsonObject))?.also { a ->
this.whoAccount = a this.whoAccount = a
client.publishApiProgress("") // カラムヘッダの再表示 client.publishApiProgress("") // カラムヘッダの再表示
} }
@ -134,7 +135,7 @@ suspend fun Column.loadProfileAccount(
else -> client.request( else -> client.request(
"/api/v1/accounts/$profileId" "/api/v1/accounts/$profileId"
)?.also { result1 -> )?.also { result1 ->
TootAccountRef.mayNull(parser, parser.account(result1.jsonObject))?.also { a -> tootAccountRefOrNull(parser, parser.account(result1.jsonObject))?.also { a ->
this.whoAccount = a this.whoAccount = a
this.whoFeaturedTags = null this.whoFeaturedTags = null

View File

@ -95,21 +95,22 @@ object DlgConfirm {
suspend fun AppCompatActivity.confirm(@StringRes messageId: Int, vararg args: Any?) = suspend fun AppCompatActivity.confirm(@StringRes messageId: Int, vararg args: Any?) =
confirm(getString(messageId, *args)) confirm(getString(messageId, *args))
suspend fun AppCompatActivity.confirm(message: CharSequence) { suspend fun AppCompatActivity.confirm(message: CharSequence, title: CharSequence? = null) {
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
try { try {
val views = DlgConfirmBinding.inflate(layoutInflater) val views = DlgConfirmBinding.inflate(layoutInflater)
views.tvMessage.text = message views.tvMessage.text = message
views.cbSkipNext.visibility = View.GONE views.cbSkipNext.visibility = View.GONE
val dialog = AlertDialog.Builder(this) val dialog = AlertDialog.Builder(this).apply {
.setView(views.root) setView(views.root)
.setCancelable(true) setCancelable(true)
.setNegativeButton(R.string.cancel, null) title?.let { setTitle(it) }
.setPositiveButton(R.string.ok) { _, _ -> setNegativeButton(R.string.cancel, null)
setPositiveButton(R.string.ok) { _, _ ->
if (cont.isActive) cont.resume(Unit) if (cont.isActive) cont.resume(Unit)
} }
.create() }.create()
dialog.setOnDismissListener { dialog.setOnDismissListener {
if (cont.isActive) cont.resumeWithException(CancellationException("dialog closed.")) if (cont.isActive) cont.resumeWithException(CancellationException("dialog closed."))
} }

View File

@ -7,6 +7,7 @@ import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.util.data.JsonArray import jp.juggler.util.data.JsonArray
import jp.juggler.util.data.JsonObject import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.notEmpty import jp.juggler.util.data.notEmpty
import jp.juggler.util.data.toMutableMap
sealed interface EmojiBase sealed interface EmojiBase
@ -52,7 +53,6 @@ class UnicodeEmoji(
} }
class CustomEmoji( class CustomEmoji(
val apDomain: Host,
val shortcode: String, // shortcode (コロンを含まない) val shortcode: String, // shortcode (コロンを含まない)
val url: String, // 画像URL val url: String, // 画像URL
val staticUrl: String?, // アニメーションなしの画像URL val staticUrl: String?, // アニメーションなしの画像URL
@ -63,7 +63,6 @@ class CustomEmoji(
) : EmojiBase, Mappable<String> { ) : EmojiBase, Mappable<String> {
fun makeAlias(alias: String) = CustomEmoji( fun makeAlias(alias: String) = CustomEmoji(
apDomain = apDomain,
shortcode = shortcode, shortcode = shortcode,
url = url, url = url,
staticUrl = staticUrl, staticUrl = staticUrl,
@ -80,9 +79,8 @@ class CustomEmoji(
companion object { companion object {
val decode: (Host, Host, JsonObject) -> CustomEmoji = { apDomain, _, src -> fun decodeMastodon(src: JsonObject): CustomEmoji {
CustomEmoji( return CustomEmoji(
apDomain = apDomain,
shortcode = src.stringOrThrow("shortcode"), shortcode = src.stringOrThrow("shortcode"),
url = src.stringOrThrow("url"), url = src.stringOrThrow("url"),
staticUrl = src.string("static_url"), staticUrl = src.string("static_url"),
@ -91,24 +89,20 @@ class CustomEmoji(
) )
} }
val decodeMisskey: (Host, Host, JsonObject) -> CustomEmoji = { apDomain, _, src -> fun decodeMisskey(src: JsonObject): CustomEmoji {
val url = src.string("url") ?: error("missing url") val url = src.string("url") ?: error("missing url")
return CustomEmoji(
CustomEmoji(
apDomain = apDomain,
shortcode = src.string("name") ?: error("missing name"), shortcode = src.string("name") ?: error("missing name"),
url = url, url = url,
staticUrl = url, staticUrl = url,
aliases = parseAliases(src.jsonArray("aliases")),
category = src.string("category"), category = src.string("category"),
) )
} }
val decodeMisskey13: (Host, Host, JsonObject) -> CustomEmoji = { apDomain, apiHost, src -> fun decodeMisskey13(apiHost: Host, src: JsonObject): CustomEmoji {
val name = src.string("name") ?: error("missing name") val name = src.string("name") ?: error("missing name")
val url = "https://${apiHost.ascii}/emoji/$name.webp" val url = "https://${apiHost.ascii}/emoji/$name.webp"
CustomEmoji( return CustomEmoji(
apDomain = apDomain,
shortcode = name, shortcode = name,
url = url, url = url,
staticUrl = url, staticUrl = url,
@ -117,6 +111,20 @@ class CustomEmoji(
) )
} }
// 入力は name→URLの単純なマップ
fun decodeMisskey12ReactionEmojis(src: JsonObject?): MutableMap<String, CustomEmoji>? =
src?.entries?.mapNotNull {
val (k, v) = it
when (val url = (v as? String)) {
null, "" -> null
else -> k to CustomEmoji(
shortcode = k,
url = url,
staticUrl = url + (if (url.contains('?')) '&' else '?') + "static=1",
)
}
}?.notEmpty()?.toMutableMap()
private fun parseAliases(src: JsonArray?): ArrayList<String>? { private fun parseAliases(src: JsonArray?): ArrayList<String>? {
var dst = null as ArrayList<String>? var dst = null as ArrayList<String>?
if (src != null) { if (src != null) {

View File

@ -121,7 +121,7 @@ enum class NotificationChannels(
; ;
fun isDissabled(context: Context) = !isEnabled(context) fun isDisabled(context: Context) = !isEnabled(context)
fun isEnabled(context: Context): Boolean { fun isEnabled(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= 33) { if (Build.VERSION.SDK_INT >= 33) {
@ -175,22 +175,26 @@ enum class NotificationChannels(
text: String? = context.getString(descId), text: String? = context.getString(descId),
piTap: PendingIntent? = null, piTap: PendingIntent? = null,
piDelete: PendingIntent? = null, piDelete: PendingIntent? = null,
force:Boolean = false,
): ForegroundInfo? { ): ForegroundInfo? {
if (Build.VERSION.SDK_INT >= 33) { val notificationManager = NotificationManagerCompat.from(context)
if (ActivityCompat.checkSelfPermission(
context, if(!force){
Manifest.permission.POST_NOTIFICATIONS if (Build.VERSION.SDK_INT >= 33) {
) != PackageManager.PERMISSION_GRANTED if (ActivityCompat.checkSelfPermission(
) { context,
log.w("[$id] missing POST_NOTIFICATIONS.") Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
log.w("[$id] missing POST_NOTIFICATIONS.")
return null
}
}
if (!notificationManager.isChannelEnabled(id)) {
log.w("[$id] notification channel is disabled.")
return null return null
} }
} }
val notificationManager = NotificationManagerCompat.from(context)
if (!notificationManager.isChannelEnabled(id)) {
log.w("[$id] notification channel is disabled.")
return null
}
val nc = this val nc = this
val builder = NotificationCompat.Builder(context, nc.id).apply { val builder = NotificationCompat.Builder(context, nc.id).apply {
priority = nc.priority priority = nc.priority

View File

@ -328,6 +328,13 @@ class PollingChecker(
val notification = parser.notification(src) ?: return val notification = parser.notification(src) ?: return
// プッシュ通知で既出なら通知しない
// プルの場合同じ通知が何度もここを通るので、既出フラグを立てない
if (daoNotificationShown.isDuplicate(account.acct, notification.id.toString())) {
log.i("update_sub: skip duplicate. ${account.acct} ${notification.id}")
return
}
// アプリミュートと単語ミュート // アプリミュートと単語ミュート
if (notification.status?.checkMuted() == true) return if (notification.status?.checkMuted() == true) return

View File

@ -58,7 +58,7 @@ fun AppCompatActivity.resetNotificationTracking(account: SavedAccount) {
} }
launchAndShowError { launchAndShowError {
withContext(AppDispatchers.IO){ withContext(AppDispatchers.IO){
daoNotificationShown.cleayByAcct(account.acct.ascii) daoNotificationShown.cleayByAcct(account.acct)
PollingChecker.accountMutex(account.db_id).withLock { PollingChecker.accountMutex(account.db_id).withLock {
daoNotificationTracking.resetTrackingState(account.db_id) daoNotificationTracking.resetTrackingState(account.db_id)
} }
@ -232,7 +232,7 @@ suspend fun checkNoticifationAll(
} }
} }
daoSavedAccount.loadAccountList().mapNotNull { sa -> daoSavedAccount.loadRealAccounts().mapNotNull { sa ->
when { when {
sa.isPseudo || !sa.isConfirmed -> null sa.isPseudo || !sa.isConfirmed -> null
else -> EmptyScope.launch(AppDispatchers.DEFAULT) { else -> EmptyScope.launch(AppDispatchers.DEFAULT) {

View File

@ -158,9 +158,10 @@ class PollingWorker2(
private fun messageToForegroundInfo( private fun messageToForegroundInfo(
text: String, text: String,
force:Boolean =false
): ForegroundInfo? { ): ForegroundInfo? {
// テキストが変化していないなら更新しない // テキストが変化していないなら更新しない
if (text.isEmpty() || text == lastMessage) return null if (!force && (text.isEmpty() || text == lastMessage)) return null
lastMessage = text lastMessage = text
log.i(text) log.i(text)
@ -181,6 +182,15 @@ class PollingWorker2(
context, context,
text = text, text = text,
piTap = piTap, piTap = piTap,
force = force,
) )
} }
/**
* ワーカーの初期化時にOSから呼ばれる場合がある
* - Android 11 moto g31 で発生
* - ダミーメッセージを仕込んだForegroundInfoを返す
*/
override suspend fun getForegroundInfo(): ForegroundInfo =
messageToForegroundInfo("initializing…",force=true)!!
} }

View File

@ -189,7 +189,7 @@ object PrefB {
) )
val bpMoveNotificationsQuickFilter = BooleanPref( val bpMoveNotificationsQuickFilter = BooleanPref(
"MoveNotificationsQuickFilter", "MoveNotificationsQuickFilter",
false true
) )
val bpShowAcctInSystemNotification = BooleanPref( val bpShowAcctInSystemNotification = BooleanPref(
"ShowAcctInSystemNotification", "ShowAcctInSystemNotification",

View File

@ -3,6 +3,7 @@ package jp.juggler.subwaytooter.push
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.TootNotification
import jp.juggler.subwaytooter.table.PushMessage import jp.juggler.subwaytooter.table.PushMessage
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
@ -14,42 +15,42 @@ enum class PushMessageIconColor(
val keys: Array<String>, val keys: Array<String>,
) { ) {
Favourite( Favourite(
R.color.colorNotificationAccentFavourite, 0,
R.drawable.ic_star_outline, R.drawable.ic_star_outline,
arrayOf("favourite"), arrayOf("favourite"),
), ),
Mention( Mention(
R.color.colorNotificationAccentMention, 0,
R.drawable.outline_alternate_email_24, R.drawable.outline_alternate_email_24,
arrayOf("mention"), arrayOf("mention"),
), ),
Reply( Reply(
R.color.colorNotificationAccentReply, 0,
R.drawable.ic_reply, R.drawable.ic_reply,
arrayOf("reply") arrayOf("reply")
), ),
Reblog( Reblog(
R.color.colorNotificationAccentReblog, 0,
R.drawable.ic_repeat, R.drawable.ic_repeat,
arrayOf("reblog", "renote"), arrayOf("reblog", "renote"),
), ),
Quote( Quote(
R.color.colorNotificationAccentQuote, 0,
R.drawable.ic_quote, R.drawable.ic_quote,
arrayOf("quote"), arrayOf("quote"),
), ),
Follow( Follow(
R.color.colorNotificationAccentFollow, 0,
R.drawable.ic_person_add, R.drawable.ic_person_add,
arrayOf("follow", "followRequestAccepted") arrayOf("follow", "followRequestAccepted")
), ),
Unfollow( Unfollow(
R.color.colorNotificationAccentUnfollow, 0,
R.drawable.ic_follow_cross, R.drawable.ic_follow_cross,
arrayOf("unfollow") arrayOf("unfollow")
), ),
Reaction( Reaction(
R.color.colorNotificationAccentReaction, 0,
R.drawable.outline_add_reaction_24, R.drawable.outline_add_reaction_24,
arrayOf("reaction", "emoji_reaction", "pleroma:emoji_reaction") arrayOf("reaction", "emoji_reaction", "pleroma:emoji_reaction")
), ),
@ -59,25 +60,30 @@ enum class PushMessageIconColor(
arrayOf("follow_request", "receiveFollowRequest"), arrayOf("follow_request", "receiveFollowRequest"),
), ),
Poll( Poll(
R.color.colorNotificationAccentPoll, 0,
R.drawable.outline_poll_24, R.drawable.outline_poll_24,
arrayOf("pollVote", "poll_vote", "poll"), arrayOf("pollVote", "poll_vote", "poll"),
), ),
Status( Status(
R.color.colorNotificationAccentStatus, 0,
R.drawable.ic_edit, R.drawable.ic_edit,
arrayOf("status", "update", "status_reference") arrayOf("status", "update", "status_reference")
), ),
SignUp( AdminSignUp(
R.color.colorNotificationAccentSignUp, 0,
R.drawable.outline_group_add_24, R.drawable.outline_group_add_24,
arrayOf("admin.sign_up"), arrayOf(TootNotification.TYPE_ADMIN_SIGNUP),
),
AdminReport(
R.color.colorNotificationAccentAdminReport,
R.drawable.ic_error,
arrayOf(TootNotification.TYPE_ADMIN_REPORT),
), ),
Unknown( Unknown(
R.color.colorNotificationAccentUnknown, R.color.colorNotificationAccentUnknown,
R.drawable.ic_question, R.drawable.ic_question,
arrayOf("unknown", "admin.sign_up"), arrayOf("unknown"),
) )
; ;

View File

@ -7,8 +7,10 @@ import jp.juggler.crypt.generateKeyPair
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.ApiError import jp.juggler.subwaytooter.api.ApiError
import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.TootAccount.Companion.tootAccount
import jp.juggler.subwaytooter.api.entity.TootNotification import jp.juggler.subwaytooter.api.entity.TootNotification
import jp.juggler.subwaytooter.api.entity.TootStatus import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.api.entity.parseItem
import jp.juggler.subwaytooter.api.push.ApiPushMisskey import jp.juggler.subwaytooter.api.push.ApiPushMisskey
import jp.juggler.subwaytooter.pref.PrefDevice import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.pref.lazyContext import jp.juggler.subwaytooter.pref.lazyContext
@ -172,11 +174,28 @@ class PushMisskey(
when (val eventType = json.string("type")) { when (val eventType = json.string("type")) {
"notification" -> { "notification" -> {
val body = json.jsonObject("body")
?: error("missing body of notification")
val parser = TootParser(context, a) val parser = TootParser(context, a)
val notification = parser.notification(json.jsonObject("body"))
?: error("can't parse notification. json=$json")
val user = notification.account val whoJson = body.jsonObject("user")
var who = parseItem(whoJson) { tootAccount(parser, it) }
body.jsonObject("note")?.let { noteJson ->
if (noteJson["user"] == null) {
noteJson["user"] = when (noteJson.string("userId")) {
null, "" -> null
who?.id?.toString() -> whoJson
a.loginAccount?.id?.toString() -> a.loginAccount?.json
else -> null
}
}
}
val notification = parser.notification(body)
?: error("can't parse notification. json=$body")
who = notification.account
// アプリミュートと単語ミュート // アプリミュートと単語ミュート
if (notification.status?.checkMuted() == true) { if (notification.status?.checkMuted() == true) {
@ -191,7 +210,7 @@ class PushMisskey(
TootNotification.TYPE_FOLLOW_REQUEST, TootNotification.TYPE_FOLLOW_REQUEST,
TootNotification.TYPE_FOLLOW_REQUEST_MISSKEY, TootNotification.TYPE_FOLLOW_REQUEST_MISSKEY,
-> { -> {
val whoAcct = a.getFullAcct(user) val whoAcct = a.getFullAcct(who)
if (TootStatus.favMuteSet?.contains(whoAcct) == true) { if (TootStatus.favMuteSet?.contains(whoAcct) == true) {
error("muted by favMuteSet ${whoAcct.pretty}") error("muted by favMuteSet ${whoAcct.pretty}")
} }
@ -200,8 +219,9 @@ class PushMisskey(
// バッジ画像のURLはない。通知種別により決まる // バッジ画像のURLはない。通知種別により決まる
pm.iconSmall = null pm.iconSmall = null
pm.iconLarge = a.supplyBaseUrl(user?.avatar_static) pm.iconLarge = a.supplyBaseUrl(who?.avatar_static)
pm.notificationType = notification.type pm.notificationType = notification.type
pm.notificationId = notification.id.toString()
json.long("dateTime")?.let { pm.timestamp = it } json.long("dateTime")?.let { pm.timestamp = it }
@ -224,3 +244,67 @@ class PushMisskey(
} }
} }
} }
/*
Misskey13
{
"type": "notification",
"body": {
"id": "9ayflq5wj4",
"createdAt": "2023-02-07T23:22:38.132Z",
"type": "reaction",
"isRead": false,
"userId": "80jbzppr37",
"user": {
"id": "80jbzppr37",
"name": "tateisu🔧",
"username": "tateisu",
"host": "fedibird.com",
"avatarUrl": "https://nos3.arkjp.net/avatar.webp?url=https%3A%2F%2Fs3.fedibird.com%2Faccounts%2Favatars%2F000%2F010%2F223%2Foriginal%2Fb7ace6ef7eaaf49f.png&avatar=1",
"avatarBlurhash": "yMMHS-t71NWX~qx]%2yEf6i_kCoKn%M{tSkCoJaeM{ayoeyEWBxtt7IAWBWqShkCi_WBt7jZRkMxayt6aeWray%Mxvj[oeofM|WBRj",
"isBot": false,
"isCat": false,
"instance": {
"name": "Fedibird",
"softwareName": "fedibird",
"softwareVersion": "0.1",
"iconUrl": "https://fedibird.com/android-chrome-192x192.png",
"faviconUrl": "https://fedibird.com/favicon.ico",
"themeColor": "#282c37"
},
"emojis": {},
"onlineStatus": "unknown"
},
"note": {
"id": "9aybef5b1d",
"createdAt": "2023-02-07T21:24:58.799Z",
"userId": "7rm6y6thc1",
"text": "(📎1)",
"visibility": "public",
"localOnly": false,
"renoteCount": 0,
"repliesCount": 0,
"reactions": {
"👍": 1,
":kakkoii@.:": 1,
":utsukushii@.:": 1
},
"reactionEmojis": {
"blobcatlobster_MUDAMUDAMUDA@fedibird.com": "https://nos3.arkjp.net/emoji.webp?url=https%3A%2F%2Fs3.fedibird.com%2Fcustom_emojis%2Fimages%2F000%2F151%2F856%2Foriginal%2F936dd0a34673cb19.png"
},
"fileIds": [
"9aybedosdl"
],
"files": [...],
],
"replyId": null,
"renoteId": null
},
"reaction": "👍"
},
"userId": "7rm6y6thc1",
"dateTime": 1675812160174
}
*/

View File

@ -12,7 +12,6 @@ import androidx.work.await
import jp.juggler.crypt.* import jp.juggler.crypt.*
import jp.juggler.subwaytooter.ActCallback import jp.juggler.subwaytooter.ActCallback
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.subwaytooter.api.entity.TootStatus import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.api.push.ApiPushAppServer import jp.juggler.subwaytooter.api.push.ApiPushAppServer
import jp.juggler.subwaytooter.api.push.ApiPushMastodon import jp.juggler.subwaytooter.api.push.ApiPushMastodon
@ -46,19 +45,23 @@ import java.util.concurrent.TimeUnit
private val log = LogCategory("PushRepo") private val log = LogCategory("PushRepo")
private val defaultOkHttp by lazy {
OkHttpClient.Builder().apply {
connectTimeout(60, TimeUnit.SECONDS)
writeTimeout(60, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
}.build()
}
val Context.pushRepo: PushRepo val Context.pushRepo: PushRepo
get() { get() {
val okHttp = OkHttpClient.Builder().apply { val okHttp = defaultOkHttp
connectTimeout(60, TimeUnit.SECONDS)
writeTimeout(60, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
}.build()
val appDatabase = appDatabase val appDatabase = appDatabase
return PushRepo( return PushRepo(
context = applicationContextSafe, context = applicationContextSafe,
apiPushAppServer = ApiPushAppServer(okHttp), apiAppServer = ApiPushAppServer(okHttp),
apiPushMastodon = ApiPushMastodon(okHttp), apiMastodon = ApiPushMastodon(okHttp),
apiPushMisskey = ApiPushMisskey(okHttp), apiMisskey = ApiPushMisskey(okHttp),
daoSavedAccount = SavedAccount.Access(appDatabase, this), daoSavedAccount = SavedAccount.Access(appDatabase, this),
daoPushMessage = PushMessage.Access(appDatabase), daoPushMessage = PushMessage.Access(appDatabase),
daoStatus = AccountNotificationStatus.Access(appDatabase), daoStatus = AccountNotificationStatus.Access(appDatabase),
@ -70,9 +73,9 @@ val Context.pushRepo: PushRepo
class PushRepo( class PushRepo(
private val context: Context, private val context: Context,
private val apiPushMastodon: ApiPushMastodon, private val apiMastodon: ApiPushMastodon,
private val apiPushMisskey: ApiPushMisskey, private val apiMisskey: ApiPushMisskey,
private val apiPushAppServer: ApiPushAppServer, private val apiAppServer: ApiPushAppServer,
private val daoSavedAccount: SavedAccount.Access, private val daoSavedAccount: SavedAccount.Access,
private val daoPushMessage: PushMessage.Access, private val daoPushMessage: PushMessage.Access,
private val daoStatus: AccountNotificationStatus.Access, private val daoStatus: AccountNotificationStatus.Access,
@ -92,7 +95,7 @@ class PushRepo(
private val pushMisskey by lazy { private val pushMisskey by lazy {
PushMisskey( PushMisskey(
context = context, context = context,
api = apiPushMisskey, api = apiMisskey,
provider = provider, provider = provider,
prefDevice = prefDevice, prefDevice = prefDevice,
daoStatus = daoStatus, daoStatus = daoStatus,
@ -102,7 +105,7 @@ class PushRepo(
private val pushMastodon by lazy { private val pushMastodon by lazy {
PushMastodon( PushMastodon(
context = context, context = context,
api = apiPushMastodon, api = apiMastodon,
provider = provider, provider = provider,
prefDevice = prefDevice, prefDevice = prefDevice,
daoStatus = daoStatus, daoStatus = daoStatus,
@ -220,7 +223,7 @@ class PushRepo(
prefDevice.fcmTokenExpired.notEmpty()?.let { prefDevice.fcmTokenExpired.notEmpty()?.let {
refReporter?.get()?.setMessage("期限切れのFCMデバイストークンをアプリサーバから削除しています") refReporter?.get()?.setMessage("期限切れのFCMデバイストークンをアプリサーバから削除しています")
log.i("remove fcmTokenExpired") log.i("remove fcmTokenExpired")
apiPushAppServer.endpointRemove(fcmToken = it) apiAppServer.endpointRemove(fcmToken = it)
prefDevice.fcmTokenExpired = null prefDevice.fcmTokenExpired = null
} }
} catch (ex: Throwable) { } catch (ex: Throwable) {
@ -232,15 +235,15 @@ class PushRepo(
prefDevice.upEndpointExpired.notEmpty()?.let { prefDevice.upEndpointExpired.notEmpty()?.let {
refReporter?.get()?.setMessage("期限切れのUnifiedPushエンドポイントをアプリサーバから削除しています") refReporter?.get()?.setMessage("期限切れのUnifiedPushエンドポイントをアプリサーバから削除しています")
log.i("remove upEndpointExpired") log.i("remove upEndpointExpired")
apiPushAppServer.endpointRemove(upUrl = it) apiAppServer.endpointRemove(upUrl = it)
prefDevice.upEndpointExpired = null prefDevice.upEndpointExpired = null
} }
} catch (ex: Throwable) { } catch (ex: Throwable) {
log.w(ex, "can't forgot upEndpointExpired") log.w(ex, "can't forgot upEndpointExpired")
} }
val realAccounts = daoSavedAccount.loadAccountList() val realAccounts = daoSavedAccount.loadRealAccounts()
.filter { !it.isPseudo } .filter { !it.isPseudo && it.isConfirmed }
val accts = realAccounts.map { it.acct } val accts = realAccounts.map { it.acct }
@ -269,23 +272,34 @@ class PushRepo(
prefDevice.pushDistributor = null prefDevice.pushDistributor = null
} }
log.i("pushDistributor=${prefDevice.pushDistributor}")
val acctHashList = acctHashMap.keys.toList() val acctHashList = acctHashMap.keys.toList()
val json = when (prefDevice.pushDistributor) { val json = when (prefDevice.pushDistributor) {
null, "" -> when { null, "" -> when {
fcmHandler.hasFcm -> registerEndpointFcm(acctHashList) fcmHandler.hasFcm -> {
log.i("registerEndpoint dist=FCM(default), acctHashList=${acctHashList.size}")
registerEndpointFcm(acctHashList)
}
else -> { else -> {
log.w("pushDistributor not selected. but can't select default distributor from background service.") log.w("pushDistributor not selected. but can't select default distributor from background service.")
null return
} }
} }
PrefDevice.PUSH_DISTRIBUTOR_NONE -> { PrefDevice.PUSH_DISTRIBUTOR_NONE -> {
log.i("push distrobuter 'none' is selected. it will remove subscription.")
willRemoveSubscription = true willRemoveSubscription = true
null null
} }
PrefDevice.PUSH_DISTRIBUTOR_FCM -> registerEndpointFcm(acctHashList) PrefDevice.PUSH_DISTRIBUTOR_FCM -> {
else -> registerEndpointUnifiedPush(acctHashList) log.i("registerEndpoint dist=FCM, acctHashList=${acctHashList.size}")
registerEndpointFcm(acctHashList)
}
else -> {
log.i("registerEndpoint dist=${prefDevice.pushDistributor}, acctHashList=${acctHashList.size}")
registerEndpointUnifiedPush(acctHashList)
}
} }
when { when {
json.isNullOrEmpty() -> json.isNullOrEmpty() ->
log.i("no information of appServerHash.") log.i("no information of appServerHash.")
@ -355,8 +369,7 @@ class PushRepo(
null null
} }
else -> { else -> {
log.i("endpointUpsert up ") apiAppServer.endpointUpsert(
apiPushAppServer.endpointUpsert(
upUrl = upEndpoint, upUrl = upEndpoint,
fcmToken = null, fcmToken = null,
acctHashList = acctHashList acctHashList = acctHashList
@ -371,8 +384,7 @@ class PushRepo(
null null
} }
else -> { else -> {
log.i("endpointUpsert fcm ") apiAppServer.endpointUpsert(
apiPushAppServer.endpointUpsert(
upUrl = null, upUrl = null,
fcmToken = fcmToken, fcmToken = fcmToken,
acctHashList = acctHashList acctHashList = acctHashList
@ -436,7 +448,7 @@ class PushRepo(
* *
* - 実際のアプリでは解読できたものだけを保存したいがこれは試験アプリなので * - 実際のアプリでは解読できたものだけを保存したいがこれは試験アプリなので
*/ */
suspend fun reDecode(pm: PushMessage) { suspend fun reprocess(pm: PushMessage) {
withContext(AppDispatchers.IO) { withContext(AppDispatchers.IO) {
updateMessage(pm.id, allowDupilicateNotification = true) updateMessage(pm.id, allowDupilicateNotification = true)
} }
@ -463,7 +475,7 @@ class PushRepo(
// アプリサーバから読み直す // アプリサーバから読み直す
if (map["b"] == null) { if (map["b"] == null) {
map.string("l")?.let { largeObjectId -> map.string("l")?.let { largeObjectId ->
apiPushAppServer.getLargeObject(largeObjectId) apiAppServer.getLargeObject(largeObjectId)
?.let { ?.let {
map = it.decodeBinPack() as? BinPackMap map = it.decodeBinPack() as? BinPackMap
?: error("binPack decode failed.") ?: error("binPack decode failed.")
@ -479,14 +491,14 @@ class PushRepo(
val status = daoStatus.findByAcctHash(acctHash) val status = daoStatus.findByAcctHash(acctHash)
?: error("missing status for acctHash $acctHash") ?: error("missing status for acctHash $acctHash")
val acct = status.acct.notEmpty() val acct = status.acct.takeIf { it.isValidFull }
?: error("empty acct.") ?: error("empty acct.")
val account = daoSavedAccount.loadAccountByAcct(Acct.parse(acct))
?: error("missing account for acct ${status.acct}")
pm.loginAcct = status.acct pm.loginAcct = status.acct
val account = daoSavedAccount.loadAccountByAcct(acct)
?: error("missing account for acct ${status.acct}")
decodeMessageContent(status, pm, map) decodeMessageContent(status, pm, map)
val messageJson = pm.messageJson val messageJson = pm.messageJson
@ -496,7 +508,7 @@ class PushRepo(
// メッセージに含まれるappServerHashを指定してendpoint登録を削除する // メッセージに含まれるappServerHashを指定してendpoint登録を削除する
// するとアプリサーバはSNSサーバに対してgoneを返すようになり掃除が適切に行われるはず // するとアプリサーバはSNSサーバに対してgoneを返すようになり掃除が適切に行われるはず
map.string("c").notEmpty()?.let { map.string("c").notEmpty()?.let {
val count = apiPushAppServer.endpointRemove(hashId = it).int("count") val count = apiAppServer.endpointRemove(hashId = it).int("count")
log.w("endpointRemove $count hashId=$it") log.w("endpointRemove $count hashId=$it")
} }
@ -504,29 +516,37 @@ class PushRepo(
} }
// Mastodonはなぜかアクセストークンが書いてあるので危険… // Mastodonはなぜかアクセストークンが書いてあるので危険…
val censored = messageJson.toString() val messageJsonFiltered = messageJson.toString()
.replace( .replace(
""""access_token":"[^"]+"""".toRegex(), """"access_token":"[^"]+"""".toRegex(),
""""access_token":"***"""" """"access_token":"***""""
) )
log.i("${status.acct} $censored") log.i("${status.acct} $messageJsonFiltered")
// ミュート用データを時々読む
TootStatus.updateMuteData()
// messageJsonを解釈して通知に出す内容を決める // messageJsonを解釈して通知に出す内容を決める
TootStatus.updateMuteData()
pushBase(account).formatPushMessage(account, pm) pushBase(account).formatPushMessage(account, pm)
val notificationId = pm.notificationId val notificationId = pm.notificationId
if (notificationId.isNullOrEmpty()) { if (notificationId.isNullOrEmpty()) {
error("can't show notification. missing notificationId.") log.w("can't show notification. missing notificationId.")
return
}
if (!account.canNotificationShowing(pm.notificationType)) {
log.w("notificationType ${pm.notificationType} is disabled.")
return
} }
if (!allowDupilicateNotification && if (!allowDupilicateNotification &&
daoNotificationShown.duplicateOrPut(acct, notificationId) daoNotificationShown.duplicateOrPut(acct, notificationId)
) { ) {
error("can't show notification. it's duplicate. $acct $notificationId") log.w("can't show notification. it's duplicate. $acct $notificationId")
return
} }
// 解読できた(例外が出なかった)なら通知を出す
showPushNotification(pm, account, notificationId) showPushNotification(pm, account, notificationId)
} catch (ex: Throwable) { } catch (ex: Throwable) {
log.e(ex, "updateMessage failed.") log.e(ex, "updateMessage failed.")
@ -620,8 +640,8 @@ class PushRepo(
account: SavedAccount, account: SavedAccount,
notificationId: String, notificationId: String,
) { ) {
if (ncPushMessage.isDissabled(context)) { if (ncPushMessage.isDisabled(context)) {
log.w("ncPushMessage isDissabled.") log.w("ncPushMessage isDisabled.")
return return
} }
@ -672,14 +692,18 @@ class PushRepo(
PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_IMMUTABLE
) )
// val iTap = intentActMessage(pm.messageDbId) // val iTap = intentActMessage(pm.messageDbId)
// val piTap = PendingIntent.getActivity(this, nc.pircTap, iTap, PendingIntent.FLAG_IMMUTABLE) // val piTap = PendingIntent.getActivity(this, nc.pircTap, iTap, PendingIntent.FLAG_IMMUTABLE)
ncPushMessage.notify(context, urlDelete) { ncPushMessage.notify(context, urlDelete) {
color = ContextCompat.getColor(context, iconAndColor.colorRes) color = pm.iconColor().colorRes.notZero()
?.let { ContextCompat.getColor(context, it) }
?: account.notificationAccentColor.notZero()
?: ContextCompat.getColor(context, R.color.colorOsNotificationAccent)
setSmallIcon(iconSmall) setSmallIcon(iconSmall)
iconBitmapLarge?.let { setLargeIcon(it) } iconBitmapLarge?.let { setLargeIcon(it) }
setContentTitle(pm.loginAcct) setContentTitle(pm.loginAcct?.pretty)
setContentText(pm.text) setContentText(pm.text)
setWhen(pm.timestamp) setWhen(pm.timestamp)
setContentIntent(piTap) setContentIntent(piTap)
@ -688,6 +712,8 @@ class PushRepo(
pm.textExpand.notEmpty()?.let { pm.textExpand.notEmpty()?.let {
setStyle(NotificationCompat.BigTextStyle().bigText(it)) setStyle(NotificationCompat.BigTextStyle().bigText(it))
} }
setGroup(context.packageName + ":" + account.acct.ascii)
} }
} }

View File

@ -1,7 +1,10 @@
package jp.juggler.subwaytooter.push package jp.juggler.subwaytooter.push
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.work.* import androidx.work.*
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.notification.NotificationChannels import jp.juggler.subwaytooter.notification.NotificationChannels
import jp.juggler.util.coroutine.AppDispatchers import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.data.notEmpty import jp.juggler.util.data.notEmpty
@ -15,6 +18,8 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
companion object { companion object {
private val log = LogCategory("PushWorker") private val log = LogCategory("PushWorker")
private val ncPushWorker = NotificationChannels.PushWorker
const val KEY_ACTION = "action" const val KEY_ACTION = "action"
const val KEY_ENDPOINT = "endpoint" const val KEY_ENDPOINT = "endpoint"
const val KEY_MESSAGE_ID = "messageId" const val KEY_MESSAGE_ID = "messageId"
@ -31,8 +36,8 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
fun enqueueUpEndpoint(context: Context, endpoint: String) { fun enqueueUpEndpoint(context: Context, endpoint: String) {
workDataOf( workDataOf(
PushWorker.KEY_ACTION to PushWorker.ACTION_UP_ENDPOINT, KEY_ACTION to ACTION_UP_ENDPOINT,
PushWorker.KEY_ENDPOINT to endpoint, KEY_ENDPOINT to endpoint,
).launchPushWorker(context) ).launchPushWorker(context)
} }
@ -45,8 +50,8 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
fun enqueuePushMessage(context: Context, messageId: Long) { fun enqueuePushMessage(context: Context, messageId: Long) {
workDataOf( workDataOf(
PushWorker.KEY_ACTION to PushWorker.ACTION_MESSAGE, KEY_ACTION to ACTION_MESSAGE,
PushWorker.KEY_MESSAGE_ID to messageId, KEY_MESSAGE_ID to messageId,
).launchPushWorker(context) ).launchPushWorker(context)
} }
@ -68,9 +73,7 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
} }
override suspend fun doWork(): Result = try { override suspend fun doWork(): Result = try {
NotificationChannels.PushWorker.createForegroundInfo( createForegroundInfo()?.let { setForegroundAsync(it) }
applicationContext,
)?.let{setForegroundAsync(it)}
withContext(AppDispatchers.IO) { withContext(AppDispatchers.IO) {
val pushRepo = applicationContext.pushRepo val pushRepo = applicationContext.pushRepo
when (val action = inputData.getString(KEY_ACTION)) { when (val action = inputData.getString(KEY_ACTION)) {
@ -80,7 +83,7 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
val endpoint = inputData.getString(KEY_ENDPOINT) val endpoint = inputData.getString(KEY_ENDPOINT)
?.notEmpty() ?: error("missing endpoint.") ?.notEmpty() ?: error("missing endpoint.")
pushRepo.newUpEndpoint(endpoint) pushRepo.newUpEndpoint(endpoint)
}finally{ } finally {
timeEndUpEndpoint.set(System.currentTimeMillis()) timeEndUpEndpoint.set(System.currentTimeMillis())
} }
} }
@ -89,7 +92,7 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
try { try {
val keepAliveMode = inputData.getBoolean(KEY_KEEP_ALIVE_MODE, false) val keepAliveMode = inputData.getBoolean(KEY_KEEP_ALIVE_MODE, false)
pushRepo.registerEndpoint(keepAliveMode) pushRepo.registerEndpoint(keepAliveMode)
}finally{ } finally {
timeEndRegisterEndpoint.set(System.currentTimeMillis()) timeEndRegisterEndpoint.set(System.currentTimeMillis())
} }
} }
@ -106,4 +109,32 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
log.e(ex, "doWork failed.") log.e(ex, "doWork failed.")
Result.failure() Result.failure()
} }
/**
* 時々OSに呼ばれる
* Android 11 moto g31 で発生
*/
override suspend fun getForegroundInfo(): ForegroundInfo {
return createForegroundInfo(force = true)!!
}
private fun createForegroundInfo(force: Boolean = false): ForegroundInfo? {
val context = applicationContext
// 通知タップ時のPendingIntent
val iTap = Intent(context, ActMain::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val piTap = PendingIntent.getActivity(
context,
ncPushWorker.pircTap,
iTap,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return ncPushWorker.createForegroundInfo(
context,
piTap = piTap,
force = force,
)
}
} }

View File

@ -4,17 +4,23 @@ import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Rect import android.graphics.Rect
import android.graphics.RectF import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.text.style.ReplacementSpan import android.text.style.ReplacementSpan
import androidx.annotation.DrawableRes
import androidx.annotation.IntRange import androidx.annotation.IntRange
import androidx.core.content.ContextCompat
import jp.juggler.apng.ApngFrames import jp.juggler.apng.ApngFrames
import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.pref.lazyContext
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
class NetworkEmojiSpan internal constructor( class NetworkEmojiSpan internal constructor(
private val url: String, private val url: String,
private val scale: Float = 1f, private val scale: Float = 1f,
@DrawableRes private val errorDrawableId: Int = R.drawable.outline_broken_image_24,
) : ReplacementSpan(), AnimatableSpan { ) : ReplacementSpan(), AnimatableSpan {
companion object { companion object {
@ -25,20 +31,19 @@ class NetworkEmojiSpan internal constructor(
private const val descentRatio = 0.211f private const val descentRatio = 0.211f
} }
private val mPaint = Paint() private val mPaint = Paint().apply { isFilterBitmap = true }
private val rectSrc = Rect() private val rectSrc = Rect()
private val rectDst = RectF() private val rectDst = RectF()
// フレーム探索結果を格納する構造体を確保しておく // フレーム探索結果を格納する構造体を確保しておく
private val mFrameFindResult = ApngFrames.FindFrameResult() private val mFrameFindResult = ApngFrames.FindFrameResult()
init {
mPaint.isFilterBitmap = true
}
private var invalidateCallback: AnimatableSpanInvalidator? = null private var invalidateCallback: AnimatableSpanInvalidator? = null
private var refDrawTarget: WeakReference<Any>? = null private var refDrawTarget: WeakReference<Any>? = null
private var errorDrawableCache: Drawable? = null
override fun setInvalidateCallback( override fun setInvalidateCallback(
drawTargetTag: Any, drawTargetTag: Any,
invalidateCallback: AnimatableSpanInvalidator, invalidateCallback: AnimatableSpanInvalidator,
@ -77,16 +82,26 @@ class NetworkEmojiSpan internal constructor(
bottom: Int, bottom: Int,
textPaint: Paint, textPaint: Paint,
) { ) {
if (drawFrame(canvas, x, baseline, textPaint)) return
drawError(canvas, x, baseline, textPaint)
}
private fun drawFrame(
canvas: Canvas,
x: Float,
baseline: Int,
textPaint: Paint,
): Boolean {
val invalidateCallback = this.invalidateCallback val invalidateCallback = this.invalidateCallback
if (invalidateCallback == null) { if (invalidateCallback == null) {
log.e("draw: invalidate_callback is null.") log.e("draw: invalidate_callback is null.")
return return false
} }
// APNGデータの取得 // APNGデータの取得
val frames = App1.custom_emoji_cache.getFrames(refDrawTarget, url) { val frames = App1.custom_emoji_cache.getFrames(refDrawTarget, url) {
invalidateCallback.delayInvalidate(0L) invalidateCallback.delayInvalidate(0L)
} ?: return } ?: return false
val t = when { val t = when {
PrefB.bpDisableEmojiAnimation.value -> 0L PrefB.bpDisableEmojiAnimation.value -> 0L
@ -99,13 +114,13 @@ class NetworkEmojiSpan internal constructor(
val b = mFrameFindResult.bitmap val b = mFrameFindResult.bitmap
if (b == null || b.isRecycled) { if (b == null || b.isRecycled) {
log.e("draw: bitmap is null or recycled.") log.e("draw: bitmap is null or recycled.")
return return false
} }
val srcWidth = b.width val srcWidth = b.width
val srcHeight = b.height val srcHeight = b.height
if (srcWidth < 1 || srcHeight < 1) { if (srcWidth < 1 || srcHeight < 1) {
log.e("draw: bitmap size is too small.") log.e("draw: bitmap size is too small.")
return return false
} }
rectSrc.set(0, 0, srcWidth, srcHeight) rectSrc.set(0, 0, srcWidth, srcHeight)
@ -155,5 +170,67 @@ class NetworkEmojiSpan internal constructor(
if (delay != Long.MAX_VALUE && !PrefB.bpDisableEmojiAnimation.value) { if (delay != Long.MAX_VALUE && !PrefB.bpDisableEmojiAnimation.value) {
invalidateCallback.delayInvalidate(delay) invalidateCallback.delayInvalidate(delay)
} }
return true
}
private fun drawError(
canvas: Canvas,
x: Float,
baseline: Int,
textPaint: Paint,
) {
val drawable = errorDrawableCache
?: ContextCompat.getDrawable(lazyContext, errorDrawableId)
?.also { errorDrawableCache = it }
drawable ?: return
val srcWidth = drawable.intrinsicWidth
val srcHeight = drawable.intrinsicHeight
// 絵文字の正方形のサイズ
val dstSize = textPaint.textSize * scaleRatio * scale
// ベースラインから上下方向にずらすオフセット
val cDescent = dstSize * descentRatio
val transY = baseline - dstSize + cDescent
// 絵文字のアスペクト比から描画範囲の幅と高さを決める
val dstWidth: Float
val dstHeight: Float
val aspectSrc = srcWidth.toFloat() / srcHeight.toFloat()
if (aspectSrc >= 1f) {
dstWidth = dstSize
dstHeight = dstSize / aspectSrc
} else {
dstHeight = dstSize
dstWidth = dstSize * aspectSrc
}
val dstX = (dstSize - dstWidth) / 2f
val dstY = (dstSize - dstHeight) / 2f
// rectDst.set(dstX, dstY, dstX + dstWidth, dstY + dstHeight)
canvas.save()
try {
canvas.translate(x, transY)
drawable.setBounds(
dstX.toInt(),
dstY.toInt(),
(dstX + dstWidth).toInt(),
(dstY + dstHeight).toInt()
)
drawable.draw(canvas)
} catch (ex: Throwable) {
log.w(ex, "drawBitmap failed.")
// 10月6日 18:18アプリのバージョン: 378 Sony Xperia X CompactF5321, Android 8.0
// 10月6日 11:35アプリのバージョン: 380 Samsung Galaxy S7 Edgehero2qltetmo, Android 8.0
// 10月2日 21:56アプリのバージョン: 376 Google Pixel 3blueline, Android 9
// java.lang.RuntimeException:
// at android.graphics.BaseCanvas.throwIfCannotDraw (BaseCanvas.java:55)
// at android.view.DisplayListCanvas.throwIfCannotDraw (DisplayListCanvas.java:226)
// at android.view.RecordingCanvas.drawBitmap (RecordingCanvas.java:123)
// at jp.juggler.subwaytooter.span.NetworkEmojiSpan.draw (NetworkEmojiSpan.kt:137)
} finally {
canvas.restore()
}
} }
} }

View File

@ -5,6 +5,7 @@ import jp.juggler.subwaytooter.api.TootApiCallback
import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.entity.MisskeyNoteUpdate.Companion.misskeyNoteUpdate
import jp.juggler.subwaytooter.column.onStatusRemoved import jp.juggler.subwaytooter.column.onStatusRemoved
import jp.juggler.subwaytooter.column.reloadFilter import jp.juggler.subwaytooter.column.reloadFilter
import jp.juggler.subwaytooter.column.replaceStatus import jp.juggler.subwaytooter.column.replaceStatus
@ -201,13 +202,7 @@ class StreamConnection(
log.e("$name handleMisskeyMessage: noteUpdated body is null") log.e("$name handleMisskeyMessage: noteUpdated body is null")
return return
} }
fireNoteUpdated( fireNoteUpdated(misskeyNoteUpdate(body), channelId)
MisskeyNoteUpdate(
acctGroup.account.apDomain,
acctGroup.account.apiHost,
body
), channelId
)
} }
"notification" -> { "notification" -> {

View File

@ -12,7 +12,7 @@ class AccountNotificationStatus(
// DB上のID // DB上のID
var id: Long = 0L, var id: Long = 0L,
// 該当ユーザのacct // 該当ユーザのacct
var acct: String = "", var acct: Acct = Acct.UNKNOWN,
// acctのハッシュ値 // acctのハッシュ値
var acctHash: String = "", var acctHash: String = "",
// アプリサーバから受け取ったハッシュ // アプリサーバから受け取ったハッシュ
@ -100,7 +100,7 @@ class AccountNotificationStatus(
cursor ?: error("cursor is null!") cursor ?: error("cursor is null!")
AccountNotificationStatus( AccountNotificationStatus(
id = cursor.getLong(idxId), id = cursor.getLong(idxId),
acct = cursor.getString(idxAcct), acct = Acct.parse(cursor.getString(idxAcct)),
acctHash = cursor.getString(idxAcctHash), acctHash = cursor.getString(idxAcctHash),
appServerHash = cursor.getStringOrNull(idxAppServerHash), appServerHash = cursor.getStringOrNull(idxAppServerHash),
pushKeyPrivate = cursor.getBlobOrNull(idxPushKeyPrivate), pushKeyPrivate = cursor.getBlobOrNull(idxPushKeyPrivate),
@ -119,7 +119,7 @@ class AccountNotificationStatus(
// ID以外のカラムをContentValuesに変換する // ID以外のカラムをContentValuesに変換する
fun toContentValues() = ContentValues().apply { fun toContentValues() = ContentValues().apply {
put(COL_ACCT, acct) put(COL_ACCT, acct.ascii)
put(COL_ACCT_HASH, acctHash) put(COL_ACCT_HASH, acctHash)
put(COL_APP_SERVER_HASH, appServerHash) put(COL_APP_SERVER_HASH, appServerHash)
put(COL_PUSH_KEY_PRIVATE, pushKeyPrivate) put(COL_PUSH_KEY_PRIVATE, pushKeyPrivate)
@ -154,7 +154,7 @@ class AccountNotificationStatus(
private fun newInstance(acct: Acct) = private fun newInstance(acct: Acct) =
AccountNotificationStatus( AccountNotificationStatus(
acct = acct.ascii, acct = acct,
acctHash = acct.ascii.encodeUTF8().digestSHA256().encodeBase64Url() acctHash = acct.ascii.encodeUTF8().digestSHA256().encodeBase64Url()
) )

View File

@ -4,6 +4,7 @@ import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.provider.BaseColumns import android.provider.BaseColumns
import jp.juggler.subwaytooter.api.entity.Acct import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.util.data.MetaColumns import jp.juggler.util.data.MetaColumns
import jp.juggler.util.data.TableCompanion import jp.juggler.util.data.TableCompanion
import jp.juggler.util.data.replaceTo import jp.juggler.util.data.replaceTo
@ -15,7 +16,6 @@ class NotificationShown(
var acct: String = "", var acct: String = "",
var notificationId: String = "", var notificationId: String = "",
var timeCreate: Long = System.currentTimeMillis(), var timeCreate: Long = System.currentTimeMillis(),
var timeDismiss: Long = 0L,
) { ) {
companion object : TableCompanion { companion object : TableCompanion {
private val log = LogCategory("NotificationShown") private val log = LogCategory("NotificationShown")
@ -24,13 +24,11 @@ class NotificationShown(
private const val COL_ACCT = "a" private const val COL_ACCT = "a"
private const val COL_NOTIFICATION_ID = "ni" private const val COL_NOTIFICATION_ID = "ni"
private const val COL_TIME_CREATE = "tc" private const val COL_TIME_CREATE = "tc"
private const val COL_TIME_DISMISS = "td"
private val columnList = MetaColumns(table, initialVersion = 65).apply { private val columnList = MetaColumns(table, initialVersion = 65).apply {
column(0, COL_ID, MetaColumns.TS_INT_PRIMARY_KEY_NOT_NULL) column(0, COL_ID, MetaColumns.TS_INT_PRIMARY_KEY_NOT_NULL)
column(0, COL_ACCT, MetaColumns.TS_EMPTY_NOT_NULL) column(0, COL_ACCT, MetaColumns.TS_EMPTY_NOT_NULL)
column(0, COL_NOTIFICATION_ID, MetaColumns.TS_EMPTY_NOT_NULL) column(0, COL_NOTIFICATION_ID, MetaColumns.TS_EMPTY_NOT_NULL)
column(0, COL_TIME_CREATE, MetaColumns.TS_ZERO_NOT_NULL) column(0, COL_TIME_CREATE, MetaColumns.TS_ZERO_NOT_NULL)
column(0, COL_TIME_DISMISS, MetaColumns.TS_ZERO_NOT_NULL)
createExtra = { createExtra = {
arrayOf( arrayOf(
"create unique index if not exists ${table}_a on $table($COL_ACCT,$COL_NOTIFICATION_ID)", "create unique index if not exists ${table}_a on $table($COL_ACCT,$COL_NOTIFICATION_ID)",
@ -122,24 +120,29 @@ class NotificationShown(
} }
} }
fun cleayByAcct(acct: String) { fun cleayByAcct(acct: Acct) {
db.execSQL( db.execSQL(
"delete from $table where $COL_ACCT=?", "delete from $table where $COL_ACCT=?",
arrayOf(acct) arrayOf(acct)
) )
} }
fun duplicateOrPut(acct: String, notificationId: String): Boolean { fun duplicateOrPut(acct: Acct, notificationId: String): Boolean {
try { try {
// 有効なIDがない場合は重複排除しない
when (notificationId) {
"", EntityId.DEFAULT.toString() -> return false
}
db.rawQuery( db.rawQuery(
"select $COL_ID from $table where $COL_ACCT=? and $COL_NOTIFICATION_ID=? limit 1", "select $COL_ID from $table where $COL_ACCT=? and $COL_NOTIFICATION_ID=? limit 1",
arrayOf(acct, notificationId) arrayOf(acct.ascii, notificationId)
)?.use { )?.use {
if (it.count > 0) return true if (it.count > 0) return true
} }
ContentValues().apply { ContentValues().apply {
put(COL_TIME_CREATE, System.currentTimeMillis()) put(COL_TIME_CREATE, System.currentTimeMillis())
put(COL_ACCT, acct) put(COL_ACCT, acct.ascii)
put(COL_NOTIFICATION_ID, notificationId) put(COL_NOTIFICATION_ID, notificationId)
}.replaceTo(db, table) }.replaceTo(db, table)
} catch (ex: Throwable) { } catch (ex: Throwable) {
@ -148,5 +151,23 @@ class NotificationShown(
return false return false
} }
fun isDuplicate(acct: Acct, notificationId: String): Boolean {
try {
// 有効なIDがない場合は重複排除しない
when (notificationId) {
"", EntityId.DEFAULT.toString() -> return false
}
db.rawQuery(
"select $COL_ID from $table where $COL_ACCT=? and $COL_NOTIFICATION_ID=? limit 1",
arrayOf(acct.ascii, notificationId)
)?.use {
if (it.count > 0) return true
}
} catch (ex: Throwable) {
log.e(ex, "isDuplicate failed.")
}
return false
}
} }
} }

View File

@ -16,7 +16,7 @@ data class PushMessage(
// DBの主ID // DBの主ID
var id: Long = 0L, var id: Long = 0L,
// 通知を受け取るアカウントのacct。通知のタイトルでもある // 通知を受け取るアカウントのacct。通知のタイトルでもある
var loginAcct: String? = null, var loginAcct: Acct? = null,
// 通知情報に含まれるタイムスタンプ // 通知情報に含まれるタイムスタンプ
var timestamp: Long = System.currentTimeMillis(), var timestamp: Long = System.currentTimeMillis(),
// 通知を受信/保存した時刻 // 通知を受信/保存した時刻
@ -151,7 +151,7 @@ data class PushMessage(
fun readRow(cursor: Cursor) = fun readRow(cursor: Cursor) =
PushMessage( PushMessage(
id = cursor.getLong(idxId), id = cursor.getLong(idxId),
loginAcct = cursor.getStringOrNull(idxLoginAcct), loginAcct = cursor.getStringOrNull(idxLoginAcct)?.let{Acct.parse(it)},
timestamp = cursor.getLong(idxTimestamp), timestamp = cursor.getLong(idxTimestamp),
timeSave = cursor.getLong(idxTimeSave), timeSave = cursor.getLong(idxTimeSave),
timeDismiss = cursor.getLong(idxTimeDismiss), timeDismiss = cursor.getLong(idxTimeDismiss),
@ -183,7 +183,7 @@ data class PushMessage(
// ID以外のカラムをContentValuesに変換する // ID以外のカラムをContentValuesに変換する
fun toContentValues() = ContentValues().apply { fun toContentValues() = ContentValues().apply {
put(COL_LOGIN_ACCT, loginAcct) put(COL_LOGIN_ACCT, loginAcct?.ascii)
put(COL_TIMESTAMP, timestamp) put(COL_TIMESTAMP, timestamp)
put(COL_TIME_SAVE, timeSave) put(COL_TIME_SAVE, timeSave)
put(COL_TIME_DISMISS, timeDismiss) put(COL_TIME_DISMISS, timeDismiss)
@ -253,6 +253,18 @@ data class PushMessage(
db.queryAll(TABLE, "$COL_TIME_SAVE desc") db.queryAll(TABLE, "$COL_TIME_SAVE desc")
?.use { ColIdx(it).readAll(it) } ?.use { ColIdx(it).readAll(it) }
?: emptyList() ?: emptyList()
fun deleteAccount(acct: Acct) {
try {
db.execSQL(
"delete from $TABLE where $COL_LOGIN_ACCT=?",
arrayOf(acct.ascii)
)
fireDataChanged()
} catch (ex: Throwable) {
log.e(ex, "sweep failed.")
}
}
} }
override fun hashCode() = if (id == 0L) super.hashCode() else id.hashCode() override fun hashCode() = if (id == 0L) super.hashCode() else id.hashCode()

View File

@ -115,9 +115,14 @@ class SavedAccount(
@JsonPropBoolean("notificationPullEnable", false) @JsonPropBoolean("notificationPullEnable", false)
var notificationPullEnable by jsonDelegates.boolean var notificationPullEnable by jsonDelegates.boolean
@JsonPropInt("notificationAccentColor", 0)
var notificationAccentColor by jsonDelegates.int
init { init {
log.i("ctor acctArg $acctArg") log.i("ctor acctArg $acctArg")
// acctArg はMastodonの生のやつで、ドメイン部分がない場合がある
// Acct.parse はHost部分がnullのacctになるかもしれない
val tmpAcct = Acct.parse(acctArg) val tmpAcct = Acct.parse(acctArg)
this.username = tmpAcct.username this.username = tmpAcct.username
if (username.isEmpty()) error("missing username in acct") if (username.isEmpty()) error("missing username in acct")
@ -128,6 +133,7 @@ class SavedAccount(
this.apiHost = tmpApiHost ?: tmpApDomain ?: tmpAcct.host ?: error("missing apiHost") this.apiHost = tmpApiHost ?: tmpApDomain ?: tmpAcct.host ?: error("missing apiHost")
this.apDomain = tmpApDomain ?: tmpApiHost ?: tmpAcct.host ?: error("missing apDomain") this.apDomain = tmpApDomain ?: tmpApiHost ?: tmpAcct.host ?: error("missing apDomain")
// Full Acct
this.acct = tmpAcct.followHost(apDomain) this.acct = tmpAcct.followHost(apDomain)
} }
@ -590,15 +596,24 @@ class SavedAccount(
fun loadAccountList() = fun loadAccountList() =
ArrayList<SavedAccount>().also { result -> ArrayList<SavedAccount>().also { result ->
try { try {
db.query( db.rawQuery("select * from $table", emptyArray()).use { cursor ->
table, while (cursor.moveToNext()) {
null, parse(lazyContext, cursor)?.let { result.add(it) }
null, }
null, }
null, } catch (ex: Throwable) {
null, log.e(ex, "loadAccountList failed.")
null lazyContext.showToast(
).use { cursor -> true,
ex.withCaption("(SubwayTooter) broken in-app database?")
)
}
}
fun loadRealAccounts() =
ArrayList<SavedAccount>().also { result ->
try {
db.rawQuery("select * from $table where $COL_USER not like '?%'", emptyArray()).use { cursor ->
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
parse(lazyContext, cursor)?.let { result.add(it) } parse(lazyContext, cursor)?.let { result.add(it) }
} }
@ -892,7 +907,8 @@ class SavedAccount(
TootNotification.TYPE_STATUS_REFERENCE -> notificationStatusReference TootNotification.TYPE_STATUS_REFERENCE -> notificationStatusReference
else -> false // 未知の通知はオフらない
else -> true
} }
fun getResizeConfig() = fun getResizeConfig() =

View File

@ -231,7 +231,7 @@ class CustomEmojiCache(
data = try { data = try {
App1.getHttpCached(request.url) App1.getHttpCached(request.url)
} catch (ex: Throwable) { } catch (ex: Throwable) {
log.w(ex, "get failed. url=${request.url}") log.w( "get failed. url=${request.url}")
null null
} }
te = elapsedTime te = elapsedTime

View File

@ -224,15 +224,7 @@ class CustomEmojiLister(
builder.post(JsonObject().toRequestBody()) builder.post(JsonObject().toRequestBody())
}?.decodeJsonObject() }?.decodeJsonObject()
?.jsonArray("emojis") ?.jsonArray("emojis")
?.let { emojis12 -> ?.let { parseList(it, CustomEmoji::decodeMisskey) }
parseList(emojis12) {
CustomEmoji.decodeMisskey(
accessInfo.apDomain,
accessInfo.apiHost,
it
)
}
}
// v13のemojisを読む // v13のemojisを読む
suspend fun misskeyEmojis13(): List<CustomEmoji>? = suspend fun misskeyEmojis13(): List<CustomEmoji>? =
@ -247,11 +239,7 @@ class CustomEmojiLister(
?.jsonArray("emojis") ?.jsonArray("emojis")
?.let { emojis13 -> ?.let { emojis13 ->
parseList(emojis13) { parseList(emojis13) {
CustomEmoji.decodeMisskey13( CustomEmoji.decodeMisskey13(accessInfo.apiHost, it)
accessInfo.apDomain,
accessInfo.apiHost,
it
)
} }
} }
@ -261,13 +249,7 @@ class CustomEmojiLister(
"https://$cacheKey/api/v1/custom_emojis", "https://$cacheKey/api/v1/custom_emojis",
accessInfo = accessInfo accessInfo = accessInfo
)?.let { data -> )?.let { data ->
parseList(data.decodeJsonArray()) { parseList(data.decodeJsonArray(), CustomEmoji::decodeMastodon)
CustomEmoji.decode(
accessInfo.apDomain,
accessInfo.apiHost,
it
)
}
} }
val list = when { val list = when {

View File

@ -20,8 +20,8 @@ class DecodeOptions(
var decodeEmoji: Boolean = false, var decodeEmoji: Boolean = false,
var attachmentList: ArrayList<TootAttachmentLike>? = null, var attachmentList: ArrayList<TootAttachmentLike>? = null,
var linkTag: Any? = null, var linkTag: Any? = null,
var emojiMapCustom: HashMap<String, CustomEmoji>? = null, var emojiMapCustom: Map<String, CustomEmoji>? = null,
var emojiMapProfile: HashMap<String, NicoProfileEmoji>? = null, var emojiMapProfile: Map<String, NicoProfileEmoji>? = null,
var highlightTrie: WordTrieTree? = null, var highlightTrie: WordTrieTree? = null,
var unwrapEmojiImageTag: Boolean = false, var unwrapEmojiImageTag: Boolean = false,
var enlargeCustomEmoji: Float = 1f, var enlargeCustomEmoji: Float = 1f,

View File

@ -469,6 +469,56 @@ object EmojiDecoder {
val useEmojioneShortcode = PrefB.bpEmojioneShortcode.value val useEmojioneShortcode = PrefB.bpEmojioneShortcode.value
val disableEmojiAnimation = PrefB.bpDisableEmojiAnimation.value val disableEmojiAnimation = PrefB.bpDisableEmojiAnimation.value
// カスタム絵文字のアニメーション切り替え
fun CustomEmoji.customEmojiToUrl(): String = when {
disableEmojiAnimation && staticUrl?.isNotEmpty() == true -> staticUrl
else -> this.url
}
fun findEmojiMisskey13(name: String): String? {
val cols = name.split("@", limit = 2)
val apiHostAscii = options.linkHelper?.apiHost?.ascii
// @以降にあるホスト名か、投稿者のホスト名か、閲覧先サーバのホスト名
val userHost = cols.elementAtOrNull(1)
?: options.authorDomain?.apiHost?.ascii
?: apiHostAscii
log.i(
"decodeEmoji Misskey13 c0=${cols.elementAtOrNull(0)} c1=${
cols.elementAtOrNull(1)
} apiHostAscii=$apiHostAscii, userHost=$userHost"
)
when {
// 絵文字プロクシを利用できない
apiHostAscii == null -> {
log.w("decodeEmoji Misskey13 missing apiHostAscii")
}
userHost != null && userHost != "." && userHost != apiHostAscii -> {
// 投稿者のホスト名を使う
return "https://$apiHostAscii/emoji/${
cols.elementAtOrNull(0)
}@$userHost.webp"
}
else -> {
// 存在確認せずに絵文字プロキシのURLを返す
// 閲覧先サーバの絵文字を探す
App1.custom_emoji_lister.getCachedEmoji(apiHostAscii, name)
?.let { return it.customEmojiToUrl() }
}
}
return null
}
fun findCustomEmojiUrl(name: String): String? {
emojiMapCustom?.get(name)?.customEmojiToUrl()
?.let{ return it}
val misskeyVersion = options.linkHelper?.misskeyVersion ?: 0
return if (misskeyVersion >= 13) {
findEmojiMisskey13(name = name)
} else {
null
}
}
splitShortCode(s, callback = object : ShortCodeSplitterCallback { splitShortCode(s, callback = object : ShortCodeSplitterCallback {
override fun onString(part: String) { override fun onString(part: String) {
builder.addUnicodeString(part) builder.addUnicodeString(part)
@ -487,53 +537,7 @@ object EmojiDecoder {
} }
} }
// カスタム絵文字 val url = findCustomEmojiUrl(name)
fun CustomEmoji.customEmojiToUrl(): String = when {
disableEmojiAnimation && staticUrl?.isNotEmpty() == true ->
this.staticUrl
else ->
this.url
}
fun findCustomEmojiUrl(): String? {
val misskeyVersion = options.linkHelper?.misskeyVersion ?: 0
if (misskeyVersion >= 13) {
val cols = name.split("@", limit = 2)
val apiHostAscii = options.linkHelper?.apiHost?.ascii
// @以降にあるホスト名か、投稿者のホスト名か、閲覧先サーバのホスト名
val userHost = cols.elementAtOrNull(1)
?: options.authorDomain?.apiHost?.ascii
?: apiHostAscii
log.i(
"decodeEmoji Misskey13 c0=${cols.elementAtOrNull(0)} c1=${
cols.elementAtOrNull(1)
} apiHostAscii=$apiHostAscii, userHost=$userHost"
)
when {
// 絵文字プロクシを利用できない
apiHostAscii == null -> {
log.w("decodeEmoji Misskey13 missing apiHostAscii")
}
userHost != null && userHost != "." && userHost != apiHostAscii -> {
// 投稿者のホスト名を使う
return "https://$apiHostAscii/emoji/${
cols.elementAtOrNull(0)
}@$userHost.webp"
}
else -> {
// 閲覧先サーバの絵文字を探す
App1.custom_emoji_lister.getCachedEmoji(apiHostAscii, name)
?.let { return it.customEmojiToUrl() }
}
}
}
return emojiMapCustom?.get(name)?.customEmojiToUrl()
}
val url = findCustomEmojiUrl()
if (url != null) { if (url != null) {
builder.addNetworkEmojiSpan(part, url) builder.addNetworkEmojiSpan(part, url)
return return

View File

@ -15,23 +15,27 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.LazyHeaders import com.bumptech.glide.load.model.LazyHeaders
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoader.LoadData
import com.bumptech.glide.load.resource.gif.GifDrawable import com.bumptech.glide.load.resource.gif.GifDrawable
import com.bumptech.glide.load.resource.gif.MyGifDrawable import com.bumptech.glide.load.resource.gif.MyGifDrawable
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.ImageViewTarget import com.bumptech.glide.request.target.ImageViewTarget
import com.bumptech.glide.request.target.Target import com.bumptech.glide.request.target.Target
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.util.data.clip import jp.juggler.util.data.clip
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import java.nio.ByteBuffer
class MyNetworkImageView : AppCompatImageView { class MyNetworkImageView : AppCompatImageView {
companion object {
internal val log = LogCategory("MyNetworkImageView")
}
// ロード中などに表示するDrawableのリソースID // ロード中などに表示するDrawableのリソースID
private var mDefaultImage: Drawable? = null private var mDefaultImage: Drawable? = null
@ -43,7 +47,7 @@ class MyNetworkImageView : AppCompatImageView {
// 表示したい画像のURL // 表示したい画像のURL
private var mUrl: String? = null private var mUrl: String? = null
private var mMayGif: Boolean = false private var mMayAnime: Boolean = false
// 非同期処理のキャンセル // 非同期処理のキャンセル
private var mTarget: Target<*>? = null private var mTarget: Target<*>? = null
@ -76,20 +80,19 @@ class MyNetworkImageView : AppCompatImageView {
fun setImageUrl( fun setImageUrl(
r: Float, r: Float,
url: String?, url: String?,
gifUrlArg: String? = null, animeUrl: String? = null,
) { ) {
mCornerRadius = r mCornerRadius = r
if (PrefB.bpEnableGifAnimation.value) {
val gifUrl = if (PrefB.bpEnableGifAnimation.value) gifUrlArg else null animeUrl?.notEmpty()?.let {
mUrl = it
if (gifUrl?.isNotEmpty() == true) { mMayAnime = true
mUrl = gifUrl loadImageIfNecessary()
mMayGif = true return
} else { }
mUrl = url
mMayGif = false
} }
mUrl = url
mMayAnime = false
loadImageIfNecessary() loadImageIfNecessary()
} }
@ -178,13 +181,15 @@ class MyNetworkImageView : AppCompatImageView {
val glideUrl = GlideUrl(url, glideHeaders) val glideUrl = GlideUrl(url, glideHeaders)
mTarget = if (mMayGif) { mTarget = if (mMayAnime) {
getGlide() getGlide()
?.load(glideUrl) ?.load(glideUrl)
?.listener(listener)
?.into(MyTargetGif(url)) ?.into(MyTargetGif(url))
} else { } else {
getGlide() getGlide()
?.load(glideUrl) ?.load(glideUrl)
?.listener(listener)
?.into(MyTarget(url)) ?.into(MyTarget(url))
} }
} catch (ex: Throwable) { } catch (ex: Throwable) {
@ -563,4 +568,61 @@ class MyNetworkImageView : AppCompatImageView {
} }
} }
} }
companion object {
private val log = LogCategory("MyNetworkImageView")
private val listener = MyRequestListener()
private val misskey13ModelLoader = Misskey13ModelLoader()
}
class MyRequestListener : RequestListener<Drawable> {
override fun onResourceReady(
resource: Drawable,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean,
): Boolean {
return false // Allow calling onResourceReady on the Target.
}
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean,
): Boolean {
e?.let {
log.e(it, "onLoadFailed")
it.rootCauses?.forEach { cause ->
val message = cause?.message
when {
cause == null -> Unit
message?.contains("setDataSource failed: status") == true ||
message?.contains("etDataSourceCallback failed: status") == true
-> log.w(message)
else -> log.e(cause, "caused by")
}
}
}
return false // Allow calling onLoadFailed on the Target.
}
}
class Misskey13ModelLoader : ModelLoader<String?, ByteBuffer> {
override fun handles(model: String): Boolean {
return model.startsWith("http") &&
model.contains(".webp?")
}
override fun buildLoadData(
model: String,
width: Int,
height: Int,
options: Options,
): LoadData<ByteBuffer>? {
TODO("Not yet implemented")
}
}
} }

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#ADADAD"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM19,19L5,19v-4.58l0.99,0.99 4,-4 4,4 4,-3.99L19,12.43L19,19zM19,9.59l-1.01,-1.01 -4,4.01 -4,-4 -4,4 -0.99,-1L5,5h14v4.59z"/>
</vector>

View File

@ -594,6 +594,42 @@
android:layout_weight="1" /> android:layout_weight="1" />
</LinearLayout> </LinearLayout>
<TextView
style="@style/setting_row_label"
android:id="@+id/tvNotificationAccentColor"
android:text="@string/notification_accent_color" />
<LinearLayout
style="@style/setting_row_form"
android:id="@+id/llNotificationAccentColor"
android:orientation="horizontal"
android:gravity="center_vertical"
>
<Button
android:id="@+id/btnNotificationAccentColorEdit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/edit"
android:textAllCaps="false"
/>
<Button
android:id="@+id/btnNotificationAccentColorReset"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/reset"
android:textAllCaps="false"
/>
<View android:id="@+id/vNotificationAccentColorColor"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="8dp"
/>
</LinearLayout>
<TextView <TextView
android:id="@+id/tvPushPolicyDesc" android:id="@+id/tvPushPolicyDesc"
style="@style/setting_row_label" style="@style/setting_row_label"
@ -631,7 +667,7 @@
<TextView <TextView
style="@style/setting_row_label" style="@style/setting_row_label"
android:text="@string/push_notification_use" /> android:text="@string/pull_notification_use" />
<LinearLayout style="@style/setting_row_form"> <LinearLayout style="@style/setting_row_form">

View File

@ -1237,4 +1237,6 @@
<string name="pull_notification_use">定期的なプル通知チェックを使う</string> <string name="pull_notification_use">定期的なプル通知チェックを使う</string>
<string name="push_subscription_exists_updateing">現在のプッシュ購読を更新しています…</string> <string name="push_subscription_exists_updateing">現在のプッシュ購読を更新しています…</string>
<string name="manually_update">手動Manually update</string> <string name="manually_update">手動Manually update</string>
<string name="notification_push_distributor_disabled">通知のプッシュ配送サービスが選択されていません</string>
<string name="notification_accent_color">通知のアクセント色</string>
</resources> </resources>

View File

@ -166,7 +166,8 @@
<color name="colorNotificationAccentReaction">#f5f233</color> <color name="colorNotificationAccentReaction">#f5f233</color>
<color name="colorNotificationAccentReblog">#39e3d5</color> <color name="colorNotificationAccentReblog">#39e3d5</color>
<color name="colorNotificationAccentReply">#ff3dbb</color> <color name="colorNotificationAccentReply">#ff3dbb</color>
<color name="colorNotificationAccentSignUp">#f56a33</color> <color name="colorNotificationAccentAdminSignUp">#f56a33</color>
<color name="colorNotificationAccentAdminReport">#ff0000</color>
<color name="colorNotificationAccentStatus">#33f597</color> <color name="colorNotificationAccentStatus">#33f597</color>
<color name="colorNotificationAccentUnfollow">#9433f5</color> <color name="colorNotificationAccentUnfollow">#9433f5</color>
<color name="colorNotificationAccentUnknown">#ae1aed</color> <color name="colorNotificationAccentUnknown">#ae1aed</color>

View File

@ -1253,4 +1253,6 @@
<string name="fedibird_capacities">Fedibird capacities</string> <string name="fedibird_capacities">Fedibird capacities</string>
<string name="push_subscription_exists_updateing">Updating current push subscription…</string> <string name="push_subscription_exists_updateing">Updating current push subscription…</string>
<string name="manually_update">Manually update</string> <string name="manually_update">Manually update</string>
<string name="notification_push_distributor_disabled">Notification push distributor not selected.</string>
<string name="notification_accent_color">Notification accent color</string>
</resources> </resources>

View File

@ -101,6 +101,7 @@ dependencies {
// api "io.insert-koin:koin-androidx-navigation:$koinVersion" // api "io.insert-koin:koin-androidx-navigation:$koinVersion"
// api "io.insert-koin:koin-androidx-compose:$koinVersion" // api "io.insert-koin:koin-androidx-compose:$koinVersion"
api "com.github.zjupure:webpdecoder:2.3.$glideVersion"
api "com.github.bumptech.glide:glide:$glideVersion" api "com.github.bumptech.glide:glide:$glideVersion"
api "com.github.bumptech.glide:annotations:$glideVersion" api "com.github.bumptech.glide:annotations:$glideVersion"
api("com.github.bumptech.glide:okhttp3-integration:$glideVersion") { api("com.github.bumptech.glide:okhttp3-integration:$glideVersion") {