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")
implementation project(":base")
implementation "com.github.zjupure:webpdecoder:2.3.$glideVersion"
testImplementation "junit:junit:$junitVersion"
}

View File

@ -21,3 +21,7 @@
#-renamesourcefileattribute SourceFile
-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 {
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 {

View File

@ -60,6 +60,10 @@
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 class ** { *; }
-keepclassmembers class ** { *** Companion; }

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Handler
@ -16,12 +17,16 @@ import android.view.View
import android.widget.*
import androidx.appcompat.app.AlertDialog
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.auth.AuthBase
import jp.juggler.subwaytooter.api.auth.authRepo
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment
import jp.juggler.subwaytooter.databinding.ActAccountSettingBinding
import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm
import jp.juggler.subwaytooter.dialog.actionsDialog
import jp.juggler.subwaytooter.notification.*
import jp.juggler.subwaytooter.push.PushBase
@ -64,6 +69,7 @@ import kotlin.math.max
class ActAccountSetting : AppCompatActivity(),
View.OnClickListener,
ColorPickerDialogListener,
CompoundButton.OnCheckedChangeListener,
AdapterView.OnItemSelectedListener {
companion object {
@ -84,6 +90,8 @@ class ActAccountSetting : AppCompatActivity(),
private const val ACTIVITY_STATE = "MyActivityState"
private const val COLOR_DIALOG_NOTIFICATION_ACCENT_COLOR = 1
fun createIntent(activity: Activity, ai: SavedAccount) =
Intent(activity, ActAccountSetting::class.java).apply {
putExtra(KEY_ACCOUNT_DB_ID, ai.db_id)
@ -510,6 +518,8 @@ class ActAccountSetting : AppCompatActivity(),
spResizeImage,
swNotificationPullEnabled,
swNotificationPushEnabled,
btnNotificationAccentColorEdit,
btnNotificationAccentColorReset,
).forEach { it.isEnabledAlpha = enabled }
// arrayOf(
@ -521,6 +531,7 @@ class ActAccountSetting : AppCompatActivity(),
showVisibility()
showAcctColor()
showPushSetting()
showNotificationColor()
} finally {
loadingBusy = false
}
@ -547,6 +558,8 @@ class ActAccountSetting : AppCompatActivity(),
tvPushActions.vg(usePush)
btnPushSubscription.vg(usePush)
btnPushSubscriptionNotForce.vg(usePush)
tvNotificationAccentColor.vg(usePush)
llNotificationAccentColor.vg(usePush)
}
run {
@ -688,6 +701,22 @@ class ActAccountSetting : AppCompatActivity(),
// PullNotification.openNotificationChannelSetting(
// 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() {
AlertDialog.Builder(this)
.setTitle(R.string.confirm)
.setMessage(R.string.confirm_account_remove)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ ->
launchAndShowError {
authRepo.accountRemove(account)
finish()
}
}.show()
launchAndShowError {
confirm(getString(R.string.confirm_account_remove), title = getString(R.string.confirm))
authRepo.accountRemove(account)
finish()
}
}
///////////////////////////////////////////////////
@ -1525,4 +1549,24 @@ class ActAccountSetting : AppCompatActivity(),
.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 com.bumptech.glide.Glide
import jp.juggler.subwaytooter.api.dialogOrToast
import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.subwaytooter.databinding.ActPushMessageListBinding
import jp.juggler.subwaytooter.databinding.LvPushMessageBinding
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.daoAccountNotificationStatus
import jp.juggler.subwaytooter.table.daoPushMessage
import jp.juggler.subwaytooter.table.daoSavedAccount
import jp.juggler.subwaytooter.util.permissionSpecNotification
import jp.juggler.subwaytooter.util.requester
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.data.encodeBase64Url
import jp.juggler.util.data.notBlank
import jp.juggler.util.data.notZero
import jp.juggler.util.log.LogCategory
import jp.juggler.util.os.saveToDownload
import jp.juggler.util.time.formatLocalTime
@ -59,6 +60,9 @@ class ActPushMessageList : AppCompatActivity() {
private val prNotification = permissionSpecNotification.requester {
// 特に何もしない
}
private val acctMap by lazy {
daoSavedAccount.loadRealAccounts().associateBy { it.acct }
}
override fun onCreate(savedInstanceState: Bundle?) {
prNotification.register(this)
@ -96,7 +100,7 @@ class ActPushMessageList : AppCompatActivity() {
launchAndShowError {
actionsDialog {
action(getString(R.string.push_message_re_decode)) {
pushRepo.reDecode(pm)
pushRepo.reprocess(pm)
}
action(getString(R.string.push_message_save_to_download_folder)) {
export(pm)
@ -140,7 +144,7 @@ class ActPushMessageList : AppCompatActivity() {
if (acct == null) {
println("!!secret key is not exported because missing recepients acct.")
} else {
val status = daoAccountNotificationStatus.load(Acct.parse(acct))
val status = daoAccountNotificationStatus.load(acct)
if (status == null) {
println("!!secret key is not exported because missing status for acct $acct .")
} else {
@ -157,12 +161,16 @@ class ActPushMessageList : AppCompatActivity() {
private val tintIconMap = HashMap<String, Drawable>()
fun tintIcon(ic: PushMessageIconColor) =
tintIconMap.getOrPut(ic.name) {
fun tintIcon(pm: PushMessage, ic: PushMessageIconColor) =
tintIconMap.getOrPut("${ic.name}-${pm.loginAcct}") {
val context = this
val src = ContextCompat.getDrawable(context, ic.iconId)!!
DrawableCompat.wrap(src).also {
DrawableCompat.setTint(it, ContextCompat.getColor(context, ic.colorRes))
DrawableCompat.wrap(src).also { d ->
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
lastItem = pm
val iconAndColor = pm.iconColor()
Glide.with(views.ivSmall)
.load(pm.iconSmall)
.error(tintIcon(iconAndColor))
.error(tintIcon(pm, pm.iconColor()))
.into(views.ivSmall)
Glide.with(views.ivLarge)

View File

@ -1,22 +1,29 @@
package jp.juggler.subwaytooter
import android.content.Context
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.PictureDrawable
import androidx.annotation.Nullable
import com.bumptech.glide.Glide
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.integration.webp.decoder.*
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.ResourceDecoder
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.bitmap.BitmapDrawableDecoder
import com.bumptech.glide.load.resource.transcode.ResourceTranscoder
import com.bumptech.glide.module.AppGlideModule
import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException
import java.io.IOException
import java.io.InputStream
import java.nio.ByteBuffer
import kotlin.math.min
@GlideModule
@ -73,7 +80,6 @@ class MyAppGlideModule : AppGlideModule() {
// Convert the [SVG]'s internal representation to an Android-compatible one ([Picture]).
class SvgDrawableTranscoder : ResourceTranscoder<SVG, PictureDrawable> {
@Nullable
override fun transcode(
toTranscode: Resource<SVG>,
options: Options,
@ -102,6 +108,65 @@ class MyAppGlideModule : AppGlideModule() {
registry
.register(SVG::class.java, PictureDrawable::class.java, SvgDrawableTranscoder())
.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) {

View File

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

View File

@ -383,7 +383,7 @@ fun ActMain.clickBoostBy(
columnType: ColumnType = ColumnType.BOOSTED_BY,
) {
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) {

View File

@ -164,7 +164,7 @@ fun ActMain.conversationLocal(
else ->
ColumnType.CONVERSATION
},
statusId,
params = arrayOf(statusId),
)
private val reDetailedStatusTime =
@ -501,7 +501,10 @@ fun ActMain.conversationFromTootsearch(
val replyId = status?.in_reply_to_id
when {
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)
}
}

View File

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

View File

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

View File

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

View File

@ -607,5 +607,5 @@ fun ActMain.openStatusHistory(
accessInfo: SavedAccount,
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,
) {
if (acctAscii == null) {
addColumn(pos, accessInfo, ColumnType.HASHTAG, tagWithoutSharp)
addColumn(pos, accessInfo, ColumnType.HASHTAG, params = arrayOf(tagWithoutSharp))
} else {
addColumn(
pos,
accessInfo,
ColumnType.HASHTAG_FROM_ACCT,
tagWithoutSharp,
acctAscii
params = arrayOf(tagWithoutSharp, acctAscii)
)
}
}

View File

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

View File

@ -573,7 +573,7 @@ private fun ActMain.userProfileFromUrlOrAcct(
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)
)?.let { ai ->
if (ai.matchHost(accessInfo)) {
addColumn(pos, ai, ColumnType.PROFILE, who.id)
addColumn(pos, ai, ColumnType.PROFILE, params = arrayOf(who.id))
} else {
userProfileFromUrlOrAcct(pos, ai, accessInfo.getFullAcct(who), who.url)
}
@ -613,7 +613,7 @@ fun ActMain.userProfileLocal(
) {
when {
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,
ai: SavedAccount,
type: ColumnType,
vararg params: Any,
protect: Boolean = false,
params: Array<out Any> = emptyArray(),
): Column {
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)
scrollAndLoad(index)
return col
@ -100,16 +102,16 @@ fun ActMain.addColumn(
indexArg: Int,
ai: SavedAccount,
type: ColumnType,
vararg params: Any,
): Column {
return addColumn(
PrefB.bpAllowColumnDuplication.value,
indexArg,
ai,
type,
*params
)
}
protect: Boolean = false,
params: Array<out Any> = emptyArray(),
): Column = addColumn(
PrefB.bpAllowColumnDuplication.value,
indexArg,
ai,
type,
protect = protect,
params = params,
)
fun ActMain.removeColumn(column: Column) {
val idxColumn = appState.columnIndex(column) ?: return
@ -342,7 +344,7 @@ fun ActMain.searchFromActivityResult(data: Intent?, columnType: ColumnType) =
defaultInsertPosition,
SavedAccount.na,
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()) {
addColumn(false, defaultInsertPosition, account, ColumnType.NOTIFICATIONS)
addColumn(false, defaultInsertPosition, account, ColumnType.LOCAL)
addColumn(false, defaultInsertPosition, account, ColumnType.FEDERATE)
addColumn(false, defaultInsertPosition, account, ColumnType.NOTIFICATIONS, protect = true)
addColumn(false, defaultInsertPosition, account, ColumnType.LOCAL, protect = true)
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.dialog.pickAccount
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.prefDevice
import jp.juggler.subwaytooter.push.fcmHandler
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.accountListCanSeeMyReactions
import jp.juggler.subwaytooter.util.VersionString
@ -404,7 +407,12 @@ class SideMenuAdapter(
// },
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(),
@ -524,7 +532,8 @@ class SideMenuAdapter(
ItemType.IT_NOTIFICATION_PERMISSION ->
viewOrInflate<TextView>(view, parent, R.layout.lv_sidemenu_item).apply {
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)
setCompoundDrawablesRelativeWithIntrinsicBounds(
drawable,
@ -534,11 +543,8 @@ class SideMenuAdapter(
)
setOnClickListener {
drawer.closeDrawer(GravityCompat.START)
if (actMain.prNotification.spec.listNotGranded(actMain).isNotEmpty()) {
actMain.prNotification.openAppSetting(actMain)
} else {
filterListItems()
}
notificationActionRecommend()?.second?.invoke()
filterListItems()
}
}
}
@ -581,11 +587,24 @@ class SideMenuAdapter(
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() {
list = originalList.filter {
when (it.itemType) {
ItemType.IT_NOTIFICATION_PERMISSION ->
actMain.prNotification.spec.listNotGranded(actMain).isNotEmpty()
notificationActionRecommend() != null
else -> true
}
}

View File

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

View File

@ -1,6 +1,7 @@
package jp.juggler.subwaytooter.api.auth
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootParser
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.pref.PrefL
import jp.juggler.subwaytooter.pref.lazyContext
import jp.juggler.subwaytooter.table.AcctColor
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.appDatabase
import jp.juggler.subwaytooter.table.*
import jp.juggler.util.log.LogCategory
val Context.authRepo
get() = AuthRepo(
context = this,
daoAcctColor = AcctColor.Access(appDatabase),
daoSavedAccount = SavedAccount.Access(appDatabase, lazyContext),
database = appDatabase,
)
class AuthRepo(
private val context: Context = lazyContext,
private val database: SQLiteDatabase = appDatabase,
private val daoAcctColor: AcctColor.Access =
AcctColor.Access(appDatabase),
AcctColor.Access(database),
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 {
private val log = LogCategory("AuthRepo")
@ -69,6 +72,8 @@ class AuthRepo(
PrefL.lpTabletTootDefaultAccount.value = -1L
}
daoSavedAccount.delete(account.db_id)
daoPushMessage.deleteAccount(account.acct)
daoNotificationShown.cleayByAcct(account.acct)
// appServerUnregister(context.applicationContextSafe, account)
}

View File

@ -38,7 +38,6 @@ class APTag(parser: TootParser, jsonArray: JsonArray?) {
)
} else {
emojiList[shortcode] = CustomEmoji(
apDomain = parser.apDomain,
shortcode = shortcode,
url = 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.log.LogCategory
class MisskeyNoteUpdate(apDomain: Host, apiHost: Host, src: JsonObject) {
companion object {
private val log = LogCategory("MisskeyNoteUpdate")
}
class MisskeyNoteUpdate(
val noteId: EntityId,
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 {
REACTION,
UNREACTION,
@ -16,53 +20,34 @@ class MisskeyNoteUpdate(apDomain: Host, apiHost: Host, src: JsonObject) {
VOTED
}
val noteId: EntityId
val type: Type
var reaction: String? = null
var userId: EntityId? = null
var deletedAt: Long? = null
var choice: Int? = null
var emoji: CustomEmoji? = null
companion object {
init {
noteId = EntityId.mayNull(src.string("id")) ?: error("MisskeyNoteUpdate: missing note id")
fun misskeyNoteUpdate(src: JsonObject): MisskeyNoteUpdate {
// root.body.body
val body2 = src.jsonObject("body") ?: error("MisskeyNoteUpdate: missing body")
val noteId = EntityId.mayNull(src.string("id"))
?: error("MisskeyNoteUpdate: missing note id")
when (val strType = src.string("type")) {
"reacted" -> {
type = Type.REACTION
reaction = body2.string("reaction")
userId = EntityId.mayDefault(body2.string("userId"))
emoji = body2.jsonObject("emoji")?.let {
try {
CustomEmoji.decodeMisskey(apDomain, apiHost, it)
} catch (ex: Throwable) {
log.e(ex, "can't parse custom emoji.")
null
}
}
// root.body.body
val body2 = src.jsonObject("body")
?: error("MisskeyNoteUpdate: missing body")
val type: Type = when (val strType = src.string("type")) {
"reacted" -> Type.REACTION
"unreacted" -> Type.UNREACTION
"deleted" -> Type.DELETED
"pollVoted" -> Type.VOTED
else -> error("MisskeyNoteUpdate: unknown type $strType")
}
"unreacted" -> {
type = Type.UNREACTION
reaction = body2.string("reaction")
userId = EntityId.mayDefault(body2.string("userId"))
}
"deleted" -> {
type = Type.DELETED
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")
return MisskeyNoteUpdate(
noteId = noteId,
type = type,
reaction = body2.string("reaction"),
userId = EntityId.mayNull(body2.string("userId")),
deletedAt = body2.string("deletedAt")?.let { TootStatus.parseTime(it) },
choice = body2.int("choice"),
emoji = parseItem(body2.jsonObject("emoji"), CustomEmoji::decodeMisskey),
)
}
}
}

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ package jp.juggler.subwaytooter.api.entity
import android.content.Context
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRefOrNull
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.notEmpty
@ -81,90 +82,79 @@ class TootNotification(
const val TYPE_STATUS_REFERENCE = "status_reference"
const val TYPE_SCHEDULED_STATUS = "scheduled_status"
fun tootNotification(parser: TootParser, src: JsonObject): TootNotification {
val id: EntityId
// One of: "mention", "reblog", "favourite", "follow"
val type: String
// The Account sending the notification to the user
val accountRef: TootAccountRef?
private fun tootNotificationMisskey(parser: TootParser, src: JsonObject): TootNotification {
// Misskeyの通知APIはページネーションをIDでしか行えない
// これは改善される予定 https://github.com/syuilo/misskey/issues/2275
// The Status associated with the notification, if applicable
// 投稿の更新により変更可能になる
val status: TootStatus?
val created_at: String? = src.string("createdAt")
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(
json = src,
id = id,
type = type,
id = EntityId.mayDefault(src.string("id")),
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,
status = status,
reaction = reaction,
reblog_visibility = reblog_visibility,
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
@ -172,16 +162,8 @@ class TootNotification(
fun getNotificationLine(context: Context): String {
val name = when (PrefB.bpShowAcctInSystemNotification.value) {
false -> accountRef?.decoded_display_name
true -> {
val acctPretty = accountRef?.get()?.acct?.pretty
if (acctPretty?.isNotEmpty() == true) {
"@$acctPretty"
} else {
null
}
}
true -> accountRef?.get()?.acct?.pretty?.notEmpty()?.let { "@$it" }
else -> accountRef?.decoded_display_name
} ?: "?"
return when (type) {
@ -223,7 +205,10 @@ class TootNotification(
TYPE_EMOJI_REACTION_PLEROMA,
TYPE_EMOJI_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_POLL_VOTE_MISSKEY,
@ -239,7 +224,7 @@ class TootNotification(
TYPE_POLL,
-> 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.api.TootAccountMap
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.emoji.CustomEmoji
import jp.juggler.subwaytooter.mfm.SpannableStringBuilderEx
@ -113,9 +114,9 @@ class TootStatus(
//Application from which the status was posted
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
private val created_at: String?,
@ -569,12 +570,19 @@ class TootStatus(
}
val who = parser.account(src.jsonObject("user"))
?: error("missing account")
val accountRef = TootAccountRef.tootAccountRef(parser, who)
val accountRef = tootAccountRef(parser, who)
val account = accountRef.get()
val created_at = src.string("createdAt")
// 絵文字マップはすぐ後で使うので、最初の方で読んでおく
val custom_emojis = parseMapOrNull(src.jsonArray("emojis")) {
CustomEmoji.decodeMisskey(parser.apDomain, parser.apiHost, it)
var custom_emojis: MutableMap<String, CustomEmoji>? =
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ということにする
@ -941,9 +949,7 @@ class TootStatus(
val created_at = src.string("created_at")
// 絵文字マップはすぐ後で使うので、最初の方で読んでおく
val custom_emojis = parseMapOrNull(src.jsonArray("emojis")) {
CustomEmoji.decode(parser.apDomain, parser.apiHost, it)
}
val custom_emojis = parseMapOrNull(src.jsonArray("emojis"),CustomEmoji::decodeMastodon)
val profile_emojis = when (val o = src["profile_emojis"]) {
is JsonArray -> parseMapOrNull(o) { NicoProfileEmoji(it) }
@ -981,7 +987,7 @@ class TootStatus(
time_created_at = parseTime(created_at)
media_attachments =
parseListOrNull(src.jsonArray("media_attachments")) {
tootAttachment(parser,it)
tootAttachment(parser, it)
}
val visibilityString = when {
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.entity.*
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRefOrNull
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.data.JsonArray
import jp.juggler.util.data.JsonObject
@ -23,7 +24,7 @@ private fun misskeyUnwrapRelationAccount(parser: TootParser, srcList: JsonArray,
srcList.objectList().mapNotNull {
when (val relationId = EntityId.mayNull(it.string("id"))) {
null -> null
else -> TootAccountRef.mayNull(parser, parser.account(it.jsonObject(key)))
else -> tootAccountRefOrNull(parser, parser.account(it.jsonObject(key)))
?.apply { _orderId = relationId }
}
}

View File

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

View File

@ -95,21 +95,22 @@ object DlgConfirm {
suspend fun AppCompatActivity.confirm(@StringRes messageId: Int, vararg args: Any?) =
confirm(getString(messageId, *args))
suspend fun AppCompatActivity.confirm(message: CharSequence) {
suspend fun AppCompatActivity.confirm(message: CharSequence, title: CharSequence? = null) {
suspendCancellableCoroutine { cont ->
try {
val views = DlgConfirmBinding.inflate(layoutInflater)
views.tvMessage.text = message
views.cbSkipNext.visibility = View.GONE
val dialog = AlertDialog.Builder(this)
.setView(views.root)
.setCancelable(true)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ ->
val dialog = AlertDialog.Builder(this).apply {
setView(views.root)
setCancelable(true)
title?.let { setTitle(it) }
setNegativeButton(R.string.cancel, null)
setPositiveButton(R.string.ok) { _, _ ->
if (cont.isActive) cont.resume(Unit)
}
.create()
}.create()
dialog.setOnDismissListener {
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.JsonObject
import jp.juggler.util.data.notEmpty
import jp.juggler.util.data.toMutableMap
sealed interface EmojiBase
@ -52,7 +53,6 @@ class UnicodeEmoji(
}
class CustomEmoji(
val apDomain: Host,
val shortcode: String, // shortcode (コロンを含まない)
val url: String, // 画像URL
val staticUrl: String?, // アニメーションなしの画像URL
@ -63,7 +63,6 @@ class CustomEmoji(
) : EmojiBase, Mappable<String> {
fun makeAlias(alias: String) = CustomEmoji(
apDomain = apDomain,
shortcode = shortcode,
url = url,
staticUrl = staticUrl,
@ -80,9 +79,8 @@ class CustomEmoji(
companion object {
val decode: (Host, Host, JsonObject) -> CustomEmoji = { apDomain, _, src ->
CustomEmoji(
apDomain = apDomain,
fun decodeMastodon(src: JsonObject): CustomEmoji {
return CustomEmoji(
shortcode = src.stringOrThrow("shortcode"),
url = src.stringOrThrow("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")
CustomEmoji(
apDomain = apDomain,
return CustomEmoji(
shortcode = src.string("name") ?: error("missing name"),
url = url,
staticUrl = url,
aliases = parseAliases(src.jsonArray("aliases")),
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 url = "https://${apiHost.ascii}/emoji/$name.webp"
CustomEmoji(
apDomain = apDomain,
return CustomEmoji(
shortcode = name,
url = 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>? {
var dst = null as ArrayList<String>?
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 {
if (Build.VERSION.SDK_INT >= 33) {
@ -175,22 +175,26 @@ enum class NotificationChannels(
text: String? = context.getString(descId),
piTap: PendingIntent? = null,
piDelete: PendingIntent? = null,
force:Boolean = false,
): ForegroundInfo? {
if (Build.VERSION.SDK_INT >= 33) {
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
log.w("[$id] missing POST_NOTIFICATIONS.")
val notificationManager = NotificationManagerCompat.from(context)
if(!force){
if (Build.VERSION.SDK_INT >= 33) {
if (ActivityCompat.checkSelfPermission(
context,
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
}
}
val notificationManager = NotificationManagerCompat.from(context)
if (!notificationManager.isChannelEnabled(id)) {
log.w("[$id] notification channel is disabled.")
return null
}
val nc = this
val builder = NotificationCompat.Builder(context, nc.id).apply {
priority = nc.priority

View File

@ -328,6 +328,13 @@ class PollingChecker(
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

View File

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

View File

@ -158,9 +158,10 @@ class PollingWorker2(
private fun messageToForegroundInfo(
text: String,
force:Boolean =false
): ForegroundInfo? {
// テキストが変化していないなら更新しない
if (text.isEmpty() || text == lastMessage) return null
if (!force && (text.isEmpty() || text == lastMessage)) return null
lastMessage = text
log.i(text)
@ -181,6 +182,15 @@ class PollingWorker2(
context,
text = text,
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(
"MoveNotificationsQuickFilter",
false
true
)
val bpShowAcctInSystemNotification = BooleanPref(
"ShowAcctInSystemNotification",

View File

@ -3,6 +3,7 @@ package jp.juggler.subwaytooter.push
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.TootNotification
import jp.juggler.subwaytooter.table.PushMessage
import jp.juggler.util.log.LogCategory
@ -14,42 +15,42 @@ enum class PushMessageIconColor(
val keys: Array<String>,
) {
Favourite(
R.color.colorNotificationAccentFavourite,
0,
R.drawable.ic_star_outline,
arrayOf("favourite"),
),
Mention(
R.color.colorNotificationAccentMention,
0,
R.drawable.outline_alternate_email_24,
arrayOf("mention"),
),
Reply(
R.color.colorNotificationAccentReply,
0,
R.drawable.ic_reply,
arrayOf("reply")
),
Reblog(
R.color.colorNotificationAccentReblog,
0,
R.drawable.ic_repeat,
arrayOf("reblog", "renote"),
),
Quote(
R.color.colorNotificationAccentQuote,
0,
R.drawable.ic_quote,
arrayOf("quote"),
),
Follow(
R.color.colorNotificationAccentFollow,
0,
R.drawable.ic_person_add,
arrayOf("follow", "followRequestAccepted")
),
Unfollow(
R.color.colorNotificationAccentUnfollow,
0,
R.drawable.ic_follow_cross,
arrayOf("unfollow")
),
Reaction(
R.color.colorNotificationAccentReaction,
0,
R.drawable.outline_add_reaction_24,
arrayOf("reaction", "emoji_reaction", "pleroma:emoji_reaction")
),
@ -59,25 +60,30 @@ enum class PushMessageIconColor(
arrayOf("follow_request", "receiveFollowRequest"),
),
Poll(
R.color.colorNotificationAccentPoll,
0,
R.drawable.outline_poll_24,
arrayOf("pollVote", "poll_vote", "poll"),
),
Status(
R.color.colorNotificationAccentStatus,
0,
R.drawable.ic_edit,
arrayOf("status", "update", "status_reference")
),
SignUp(
R.color.colorNotificationAccentSignUp,
AdminSignUp(
0,
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(
R.color.colorNotificationAccentUnknown,
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.api.ApiError
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.TootStatus
import jp.juggler.subwaytooter.api.entity.parseItem
import jp.juggler.subwaytooter.api.push.ApiPushMisskey
import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.pref.lazyContext
@ -172,11 +174,28 @@ class PushMisskey(
when (val eventType = json.string("type")) {
"notification" -> {
val body = json.jsonObject("body")
?: error("missing body of notification")
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) {
@ -191,7 +210,7 @@ class PushMisskey(
TootNotification.TYPE_FOLLOW_REQUEST,
TootNotification.TYPE_FOLLOW_REQUEST_MISSKEY,
-> {
val whoAcct = a.getFullAcct(user)
val whoAcct = a.getFullAcct(who)
if (TootStatus.favMuteSet?.contains(whoAcct) == true) {
error("muted by favMuteSet ${whoAcct.pretty}")
}
@ -200,8 +219,9 @@ class PushMisskey(
// バッジ画像のURLはない。通知種別により決まる
pm.iconSmall = null
pm.iconLarge = a.supplyBaseUrl(user?.avatar_static)
pm.iconLarge = a.supplyBaseUrl(who?.avatar_static)
pm.notificationType = notification.type
pm.notificationId = notification.id.toString()
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.subwaytooter.ActCallback
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.api.push.ApiPushAppServer
import jp.juggler.subwaytooter.api.push.ApiPushMastodon
@ -46,19 +45,23 @@ import java.util.concurrent.TimeUnit
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
get() {
val okHttp = OkHttpClient.Builder().apply {
connectTimeout(60, TimeUnit.SECONDS)
writeTimeout(60, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
}.build()
val okHttp = defaultOkHttp
val appDatabase = appDatabase
return PushRepo(
context = applicationContextSafe,
apiPushAppServer = ApiPushAppServer(okHttp),
apiPushMastodon = ApiPushMastodon(okHttp),
apiPushMisskey = ApiPushMisskey(okHttp),
apiAppServer = ApiPushAppServer(okHttp),
apiMastodon = ApiPushMastodon(okHttp),
apiMisskey = ApiPushMisskey(okHttp),
daoSavedAccount = SavedAccount.Access(appDatabase, this),
daoPushMessage = PushMessage.Access(appDatabase),
daoStatus = AccountNotificationStatus.Access(appDatabase),
@ -70,9 +73,9 @@ val Context.pushRepo: PushRepo
class PushRepo(
private val context: Context,
private val apiPushMastodon: ApiPushMastodon,
private val apiPushMisskey: ApiPushMisskey,
private val apiPushAppServer: ApiPushAppServer,
private val apiMastodon: ApiPushMastodon,
private val apiMisskey: ApiPushMisskey,
private val apiAppServer: ApiPushAppServer,
private val daoSavedAccount: SavedAccount.Access,
private val daoPushMessage: PushMessage.Access,
private val daoStatus: AccountNotificationStatus.Access,
@ -92,7 +95,7 @@ class PushRepo(
private val pushMisskey by lazy {
PushMisskey(
context = context,
api = apiPushMisskey,
api = apiMisskey,
provider = provider,
prefDevice = prefDevice,
daoStatus = daoStatus,
@ -102,7 +105,7 @@ class PushRepo(
private val pushMastodon by lazy {
PushMastodon(
context = context,
api = apiPushMastodon,
api = apiMastodon,
provider = provider,
prefDevice = prefDevice,
daoStatus = daoStatus,
@ -220,7 +223,7 @@ class PushRepo(
prefDevice.fcmTokenExpired.notEmpty()?.let {
refReporter?.get()?.setMessage("期限切れのFCMデバイストークンをアプリサーバから削除しています")
log.i("remove fcmTokenExpired")
apiPushAppServer.endpointRemove(fcmToken = it)
apiAppServer.endpointRemove(fcmToken = it)
prefDevice.fcmTokenExpired = null
}
} catch (ex: Throwable) {
@ -232,15 +235,15 @@ class PushRepo(
prefDevice.upEndpointExpired.notEmpty()?.let {
refReporter?.get()?.setMessage("期限切れのUnifiedPushエンドポイントをアプリサーバから削除しています")
log.i("remove upEndpointExpired")
apiPushAppServer.endpointRemove(upUrl = it)
apiAppServer.endpointRemove(upUrl = it)
prefDevice.upEndpointExpired = null
}
} catch (ex: Throwable) {
log.w(ex, "can't forgot upEndpointExpired")
}
val realAccounts = daoSavedAccount.loadAccountList()
.filter { !it.isPseudo }
val realAccounts = daoSavedAccount.loadRealAccounts()
.filter { !it.isPseudo && it.isConfirmed }
val accts = realAccounts.map { it.acct }
@ -269,23 +272,34 @@ class PushRepo(
prefDevice.pushDistributor = null
}
log.i("pushDistributor=${prefDevice.pushDistributor}")
val acctHashList = acctHashMap.keys.toList()
val json = when (prefDevice.pushDistributor) {
null, "" -> when {
fcmHandler.hasFcm -> registerEndpointFcm(acctHashList)
fcmHandler.hasFcm -> {
log.i("registerEndpoint dist=FCM(default), acctHashList=${acctHashList.size}")
registerEndpointFcm(acctHashList)
}
else -> {
log.w("pushDistributor not selected. but can't select default distributor from background service.")
null
return
}
}
PrefDevice.PUSH_DISTRIBUTOR_NONE -> {
log.i("push distrobuter 'none' is selected. it will remove subscription.")
willRemoveSubscription = true
null
}
PrefDevice.PUSH_DISTRIBUTOR_FCM -> registerEndpointFcm(acctHashList)
else -> registerEndpointUnifiedPush(acctHashList)
PrefDevice.PUSH_DISTRIBUTOR_FCM -> {
log.i("registerEndpoint dist=FCM, acctHashList=${acctHashList.size}")
registerEndpointFcm(acctHashList)
}
else -> {
log.i("registerEndpoint dist=${prefDevice.pushDistributor}, acctHashList=${acctHashList.size}")
registerEndpointUnifiedPush(acctHashList)
}
}
when {
json.isNullOrEmpty() ->
log.i("no information of appServerHash.")
@ -355,8 +369,7 @@ class PushRepo(
null
}
else -> {
log.i("endpointUpsert up ")
apiPushAppServer.endpointUpsert(
apiAppServer.endpointUpsert(
upUrl = upEndpoint,
fcmToken = null,
acctHashList = acctHashList
@ -371,8 +384,7 @@ class PushRepo(
null
}
else -> {
log.i("endpointUpsert fcm ")
apiPushAppServer.endpointUpsert(
apiAppServer.endpointUpsert(
upUrl = null,
fcmToken = fcmToken,
acctHashList = acctHashList
@ -436,7 +448,7 @@ class PushRepo(
*
* - 実際のアプリでは解読できたものだけを保存したいがこれは試験アプリなので
*/
suspend fun reDecode(pm: PushMessage) {
suspend fun reprocess(pm: PushMessage) {
withContext(AppDispatchers.IO) {
updateMessage(pm.id, allowDupilicateNotification = true)
}
@ -463,7 +475,7 @@ class PushRepo(
// アプリサーバから読み直す
if (map["b"] == null) {
map.string("l")?.let { largeObjectId ->
apiPushAppServer.getLargeObject(largeObjectId)
apiAppServer.getLargeObject(largeObjectId)
?.let {
map = it.decodeBinPack() as? BinPackMap
?: error("binPack decode failed.")
@ -479,14 +491,14 @@ class PushRepo(
val status = daoStatus.findByAcctHash(acctHash)
?: error("missing status for acctHash $acctHash")
val acct = status.acct.notEmpty()
val acct = status.acct.takeIf { it.isValidFull }
?: error("empty acct.")
val account = daoSavedAccount.loadAccountByAcct(Acct.parse(acct))
?: error("missing account for acct ${status.acct}")
pm.loginAcct = status.acct
val account = daoSavedAccount.loadAccountByAcct(acct)
?: error("missing account for acct ${status.acct}")
decodeMessageContent(status, pm, map)
val messageJson = pm.messageJson
@ -496,7 +508,7 @@ class PushRepo(
// メッセージに含まれるappServerHashを指定してendpoint登録を削除する
// するとアプリサーバはSNSサーバに対してgoneを返すようになり掃除が適切に行われるはず
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")
}
@ -504,29 +516,37 @@ class PushRepo(
}
// Mastodonはなぜかアクセストークンが書いてあるので危険…
val censored = messageJson.toString()
val messageJsonFiltered = messageJson.toString()
.replace(
""""access_token":"[^"]+"""".toRegex(),
""""access_token":"***""""
)
log.i("${status.acct} $censored")
log.i("${status.acct} $messageJsonFiltered")
// ミュート用データを時々読む
TootStatus.updateMuteData()
// messageJsonを解釈して通知に出す内容を決める
TootStatus.updateMuteData()
pushBase(account).formatPushMessage(account, pm)
val notificationId = pm.notificationId
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 &&
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)
} catch (ex: Throwable) {
log.e(ex, "updateMessage failed.")
@ -620,8 +640,8 @@ class PushRepo(
account: SavedAccount,
notificationId: String,
) {
if (ncPushMessage.isDissabled(context)) {
log.w("ncPushMessage isDissabled.")
if (ncPushMessage.isDisabled(context)) {
log.w("ncPushMessage isDisabled.")
return
}
@ -672,14 +692,18 @@ class PushRepo(
PendingIntent.FLAG_IMMUTABLE
)
// val iTap = intentActMessage(pm.messageDbId)
// val iTap = intentActMessage(pm.messageDbId)
// val piTap = PendingIntent.getActivity(this, nc.pircTap, iTap, PendingIntent.FLAG_IMMUTABLE)
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)
iconBitmapLarge?.let { setLargeIcon(it) }
setContentTitle(pm.loginAcct)
setContentTitle(pm.loginAcct?.pretty)
setContentText(pm.text)
setWhen(pm.timestamp)
setContentIntent(piTap)
@ -688,6 +712,8 @@ class PushRepo(
pm.textExpand.notEmpty()?.let {
setStyle(NotificationCompat.BigTextStyle().bigText(it))
}
setGroup(context.packageName + ":" + account.acct.ascii)
}
}

View File

@ -1,7 +1,10 @@
package jp.juggler.subwaytooter.push
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.work.*
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.notification.NotificationChannels
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.data.notEmpty
@ -15,6 +18,8 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
companion object {
private val log = LogCategory("PushWorker")
private val ncPushWorker = NotificationChannels.PushWorker
const val KEY_ACTION = "action"
const val KEY_ENDPOINT = "endpoint"
const val KEY_MESSAGE_ID = "messageId"
@ -31,8 +36,8 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
fun enqueueUpEndpoint(context: Context, endpoint: String) {
workDataOf(
PushWorker.KEY_ACTION to PushWorker.ACTION_UP_ENDPOINT,
PushWorker.KEY_ENDPOINT to endpoint,
KEY_ACTION to ACTION_UP_ENDPOINT,
KEY_ENDPOINT to endpoint,
).launchPushWorker(context)
}
@ -45,8 +50,8 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
fun enqueuePushMessage(context: Context, messageId: Long) {
workDataOf(
PushWorker.KEY_ACTION to PushWorker.ACTION_MESSAGE,
PushWorker.KEY_MESSAGE_ID to messageId,
KEY_ACTION to ACTION_MESSAGE,
KEY_MESSAGE_ID to messageId,
).launchPushWorker(context)
}
@ -68,9 +73,7 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
}
override suspend fun doWork(): Result = try {
NotificationChannels.PushWorker.createForegroundInfo(
applicationContext,
)?.let{setForegroundAsync(it)}
createForegroundInfo()?.let { setForegroundAsync(it) }
withContext(AppDispatchers.IO) {
val pushRepo = applicationContext.pushRepo
when (val action = inputData.getString(KEY_ACTION)) {
@ -80,7 +83,7 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
val endpoint = inputData.getString(KEY_ENDPOINT)
?.notEmpty() ?: error("missing endpoint.")
pushRepo.newUpEndpoint(endpoint)
}finally{
} finally {
timeEndUpEndpoint.set(System.currentTimeMillis())
}
}
@ -89,7 +92,7 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
try {
val keepAliveMode = inputData.getBoolean(KEY_KEEP_ALIVE_MODE, false)
pushRepo.registerEndpoint(keepAliveMode)
}finally{
} finally {
timeEndRegisterEndpoint.set(System.currentTimeMillis())
}
}
@ -106,4 +109,32 @@ class PushWorker(appContext: Context, workerParams: WorkerParameters) :
log.e(ex, "doWork failed.")
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.Rect
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.text.style.ReplacementSpan
import androidx.annotation.DrawableRes
import androidx.annotation.IntRange
import androidx.core.content.ContextCompat
import jp.juggler.apng.ApngFrames
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.pref.lazyContext
import jp.juggler.util.log.LogCategory
import java.lang.ref.WeakReference
class NetworkEmojiSpan internal constructor(
private val url: String,
private val scale: Float = 1f,
@DrawableRes private val errorDrawableId: Int = R.drawable.outline_broken_image_24,
) : ReplacementSpan(), AnimatableSpan {
companion object {
@ -25,20 +31,19 @@ class NetworkEmojiSpan internal constructor(
private const val descentRatio = 0.211f
}
private val mPaint = Paint()
private val mPaint = Paint().apply { isFilterBitmap = true }
private val rectSrc = Rect()
private val rectDst = RectF()
// フレーム探索結果を格納する構造体を確保しておく
private val mFrameFindResult = ApngFrames.FindFrameResult()
init {
mPaint.isFilterBitmap = true
}
private var invalidateCallback: AnimatableSpanInvalidator? = null
private var refDrawTarget: WeakReference<Any>? = null
private var errorDrawableCache: Drawable? = null
override fun setInvalidateCallback(
drawTargetTag: Any,
invalidateCallback: AnimatableSpanInvalidator,
@ -77,16 +82,26 @@ class NetworkEmojiSpan internal constructor(
bottom: Int,
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
if (invalidateCallback == null) {
log.e("draw: invalidate_callback is null.")
return
return false
}
// APNGデータの取得
val frames = App1.custom_emoji_cache.getFrames(refDrawTarget, url) {
invalidateCallback.delayInvalidate(0L)
} ?: return
} ?: return false
val t = when {
PrefB.bpDisableEmojiAnimation.value -> 0L
@ -99,13 +114,13 @@ class NetworkEmojiSpan internal constructor(
val b = mFrameFindResult.bitmap
if (b == null || b.isRecycled) {
log.e("draw: bitmap is null or recycled.")
return
return false
}
val srcWidth = b.width
val srcHeight = b.height
if (srcWidth < 1 || srcHeight < 1) {
log.e("draw: bitmap size is too small.")
return
return false
}
rectSrc.set(0, 0, srcWidth, srcHeight)
@ -155,5 +170,67 @@ class NetworkEmojiSpan internal constructor(
if (delay != Long.MAX_VALUE && !PrefB.bpDisableEmojiAnimation.value) {
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.TootApiResult
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.reloadFilter
import jp.juggler.subwaytooter.column.replaceStatus
@ -201,13 +202,7 @@ class StreamConnection(
log.e("$name handleMisskeyMessage: noteUpdated body is null")
return
}
fireNoteUpdated(
MisskeyNoteUpdate(
acctGroup.account.apDomain,
acctGroup.account.apiHost,
body
), channelId
)
fireNoteUpdated(misskeyNoteUpdate(body), channelId)
}
"notification" -> {

View File

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

View File

@ -4,6 +4,7 @@ import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import android.provider.BaseColumns
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.TableCompanion
import jp.juggler.util.data.replaceTo
@ -15,7 +16,6 @@ class NotificationShown(
var acct: String = "",
var notificationId: String = "",
var timeCreate: Long = System.currentTimeMillis(),
var timeDismiss: Long = 0L,
) {
companion object : TableCompanion {
private val log = LogCategory("NotificationShown")
@ -24,13 +24,11 @@ class NotificationShown(
private const val COL_ACCT = "a"
private const val COL_NOTIFICATION_ID = "ni"
private const val COL_TIME_CREATE = "tc"
private const val COL_TIME_DISMISS = "td"
private val columnList = MetaColumns(table, initialVersion = 65).apply {
column(0, COL_ID, MetaColumns.TS_INT_PRIMARY_KEY_NOT_NULL)
column(0, COL_ACCT, 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_DISMISS, MetaColumns.TS_ZERO_NOT_NULL)
createExtra = {
arrayOf(
"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(
"delete from $table where $COL_ACCT=?",
arrayOf(acct)
)
}
fun duplicateOrPut(acct: String, notificationId: String): Boolean {
fun duplicateOrPut(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, notificationId)
arrayOf(acct.ascii, notificationId)
)?.use {
if (it.count > 0) return true
}
ContentValues().apply {
put(COL_TIME_CREATE, System.currentTimeMillis())
put(COL_ACCT, acct)
put(COL_ACCT, acct.ascii)
put(COL_NOTIFICATION_ID, notificationId)
}.replaceTo(db, table)
} catch (ex: Throwable) {
@ -148,5 +151,23 @@ class NotificationShown(
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
var id: Long = 0L,
// 通知を受け取るアカウントのacct。通知のタイトルでもある
var loginAcct: String? = null,
var loginAcct: Acct? = null,
// 通知情報に含まれるタイムスタンプ
var timestamp: Long = System.currentTimeMillis(),
// 通知を受信/保存した時刻
@ -151,7 +151,7 @@ data class PushMessage(
fun readRow(cursor: Cursor) =
PushMessage(
id = cursor.getLong(idxId),
loginAcct = cursor.getStringOrNull(idxLoginAcct),
loginAcct = cursor.getStringOrNull(idxLoginAcct)?.let{Acct.parse(it)},
timestamp = cursor.getLong(idxTimestamp),
timeSave = cursor.getLong(idxTimeSave),
timeDismiss = cursor.getLong(idxTimeDismiss),
@ -183,7 +183,7 @@ data class PushMessage(
// ID以外のカラムをContentValuesに変換する
fun toContentValues() = ContentValues().apply {
put(COL_LOGIN_ACCT, loginAcct)
put(COL_LOGIN_ACCT, loginAcct?.ascii)
put(COL_TIMESTAMP, timestamp)
put(COL_TIME_SAVE, timeSave)
put(COL_TIME_DISMISS, timeDismiss)
@ -253,6 +253,18 @@ data class PushMessage(
db.queryAll(TABLE, "$COL_TIME_SAVE desc")
?.use { ColIdx(it).readAll(it) }
?: 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()

View File

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

View File

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

View File

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

View File

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

View File

@ -469,6 +469,56 @@ object EmojiDecoder {
val useEmojioneShortcode = PrefB.bpEmojioneShortcode.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 {
override fun onString(part: String) {
builder.addUnicodeString(part)
@ -487,53 +537,7 @@ object EmojiDecoder {
}
}
// カスタム絵文字
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()
val url = findCustomEmojiUrl(name)
if (url != null) {
builder.addNetworkEmojiSpan(part, url)
return

View File

@ -15,23 +15,27 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
import com.bumptech.glide.Glide
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.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.MyGifDrawable
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.ImageViewTarget
import com.bumptech.glide.request.target.Target
import com.bumptech.glide.request.transition.Transition
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.util.data.clip
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory
import java.nio.ByteBuffer
class MyNetworkImageView : AppCompatImageView {
companion object {
internal val log = LogCategory("MyNetworkImageView")
}
// ロード中などに表示するDrawableのリソースID
private var mDefaultImage: Drawable? = null
@ -43,7 +47,7 @@ class MyNetworkImageView : AppCompatImageView {
// 表示したい画像のURL
private var mUrl: String? = null
private var mMayGif: Boolean = false
private var mMayAnime: Boolean = false
// 非同期処理のキャンセル
private var mTarget: Target<*>? = null
@ -76,20 +80,19 @@ class MyNetworkImageView : AppCompatImageView {
fun setImageUrl(
r: Float,
url: String?,
gifUrlArg: String? = null,
animeUrl: String? = null,
) {
mCornerRadius = r
val gifUrl = if (PrefB.bpEnableGifAnimation.value) gifUrlArg else null
if (gifUrl?.isNotEmpty() == true) {
mUrl = gifUrl
mMayGif = true
} else {
mUrl = url
mMayGif = false
if (PrefB.bpEnableGifAnimation.value) {
animeUrl?.notEmpty()?.let {
mUrl = it
mMayAnime = true
loadImageIfNecessary()
return
}
}
mUrl = url
mMayAnime = false
loadImageIfNecessary()
}
@ -178,13 +181,15 @@ class MyNetworkImageView : AppCompatImageView {
val glideUrl = GlideUrl(url, glideHeaders)
mTarget = if (mMayGif) {
mTarget = if (mMayAnime) {
getGlide()
?.load(glideUrl)
?.listener(listener)
?.into(MyTargetGif(url))
} else {
getGlide()
?.load(glideUrl)
?.listener(listener)
?.into(MyTarget(url))
}
} 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" />
</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
android:id="@+id/tvPushPolicyDesc"
style="@style/setting_row_label"
@ -631,7 +667,7 @@
<TextView
style="@style/setting_row_label"
android:text="@string/push_notification_use" />
android:text="@string/pull_notification_use" />
<LinearLayout style="@style/setting_row_form">

View File

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

View File

@ -166,7 +166,8 @@
<color name="colorNotificationAccentReaction">#f5f233</color>
<color name="colorNotificationAccentReblog">#39e3d5</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="colorNotificationAccentUnfollow">#9433f5</color>
<color name="colorNotificationAccentUnknown">#ae1aed</color>

View File

@ -1253,4 +1253,6 @@
<string name="fedibird_capacities">Fedibird capacities</string>
<string name="push_subscription_exists_updateing">Updating current push subscription…</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>

View File

@ -101,6 +101,7 @@ dependencies {
// api "io.insert-koin:koin-androidx-navigation:$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:annotations:$glideVersion"
api("com.github.bumptech.glide:okhttp3-integration:$glideVersion") {