突然のWorkManager移行

This commit is contained in:
tateisu 2022-06-14 02:23:46 +09:00
parent feee046b66
commit 4a1b4f91c2
45 changed files with 1729 additions and 2178 deletions

View File

@ -264,6 +264,8 @@ dependencies {
// optional - Test helpers for LiveData
testImplementation "androidx.arch.core:core-testing:$arch_version"
implementation 'androidx.work:work-runtime-ktx:2.7.1'
def roomVersion = "2.4.2"
implementation "androidx.room:room-runtime:$roomVersion"
implementation "androidx.room:room-ktx:$roomVersion"

View File

@ -80,7 +80,6 @@
<activity
android:name=".ActMain"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden">
<intent-filter>
@ -91,8 +90,7 @@
<activity
android:name=".ActCallback"
android:exported="true"
android:label="@string/app_name">
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -346,17 +344,6 @@
</intent-filter>
</receiver>
<!-- Set custom default icon. This is used when no icon is set for incoming notification messages.
See README(https://goo.gl/l4GJaQ) for more. -->
<service
android:name=".notification.PollingService"
android:permission="android.permission.BIND_JOB_SERVICE" />
<!-- Set color used with incoming notification messages. This is used when no color is set for the incoming
notification message. See README(https://goo.gl/6BKBk7) for more. -->
<service android:name=".notification.PollingForegrounder" />
<!--https://android-developers.googleblog.com/2018/11/get-your-app-ready-for-foldable-phones.html-->
<service
android:name=".MyFirebaseMessagingService"
android:exported="true"
@ -365,6 +352,9 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name="jp.juggler.subwaytooter.notification.ForegroundPollingService"
android:exported="false" />
<provider
android:name="androidx.startup.InitializationProvider"

View File

@ -30,9 +30,10 @@ import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.databinding.ActAccountSettingBinding
import jp.juggler.subwaytooter.dialog.ActionsDialog
import jp.juggler.subwaytooter.notification.NotificationHelper
import jp.juggler.subwaytooter.notification.PollingWorker
import jp.juggler.subwaytooter.notification.MessageNotification
import jp.juggler.subwaytooter.notification.PushSubscriptionHelper
import jp.juggler.subwaytooter.notification.checkNotificationImmediate
import jp.juggler.subwaytooter.notification.resetNotificationTracking
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.pref.PrefS
import jp.juggler.subwaytooter.table.AcctColor
@ -255,7 +256,7 @@ class ActAccountSetting : AppCompatActivity(),
}
override fun onStop() {
PollingWorker.queueUpdateNotification(this)
checkNotificationImmediate(this, account.db_id)
super.onStop()
}
@ -383,14 +384,15 @@ class ActAccountSetting : AppCompatActivity(),
)
is EditText ->
it.addTextChangedListener(watcher1)
is Button ->
it.setOnClickListener(this@ActAccountSetting)
is ImageButton ->
it.setOnClickListener(this@ActAccountSetting)
is CompoundButton ->
it.setOnCheckedChangeListener(this@ActAccountSetting)
is Spinner ->
it.onItemSelectedListener = this@ActAccountSetting
// CompoundButton はButtonでもあるので上に置く
is CompoundButton ->
it.setOnCheckedChangeListener(this@ActAccountSetting)
is ImageButton ->
it.setOnClickListener(this@ActAccountSetting)
is Button ->
it.setOnClickListener(this@ActAccountSetting)
}
}
}
@ -630,12 +632,11 @@ class ActAccountSetting : AppCompatActivity(),
R.id.btnPushSubscription -> updatePushSubscription(force = true)
R.id.btnPushSubscriptionNotForce -> updatePushSubscription(force = false)
R.id.btnResetNotificationTracking ->
PollingWorker.resetNotificationTracking(this, account)
resetNotificationTracking(account)
R.id.btnUserCustom -> arShowAcctColor.launch(
ActNickname.createIntent(this, account.acct, false),
)
)
R.id.btnNotificationSoundEdit -> openNotificationSoundPicker()
@ -655,17 +656,17 @@ class ActAccountSetting : AppCompatActivity(),
R.id.btnFields -> sendFields()
R.id.btnNotificationStyleEdit ->
NotificationHelper.openNotificationChannelSetting(
MessageNotification.openNotificationChannelSetting(
this,
account,
NotificationHelper.TRACKING_NAME_DEFAULT
MessageNotification.TRACKING_NAME_DEFAULT
)
R.id.btnNotificationStyleEditReply ->
NotificationHelper.openNotificationChannelSetting(
MessageNotification.openNotificationChannelSetting(
this,
account,
NotificationHelper.TRACKING_NAME_REPLY
MessageNotification.TRACKING_NAME_REPLY
)
}
}

View File

@ -28,12 +28,14 @@ import jp.juggler.subwaytooter.appsetting.AppSettingItem
import jp.juggler.subwaytooter.appsetting.SettingType
import jp.juggler.subwaytooter.appsetting.appSettingRoot
import jp.juggler.subwaytooter.dialog.DlgAppPicker
import jp.juggler.subwaytooter.notification.PollingWorker
import jp.juggler.subwaytooter.pref.*
import jp.juggler.subwaytooter.notification.restartAllWorker
import jp.juggler.subwaytooter.pref.impl.BooleanPref
import jp.juggler.subwaytooter.pref.impl.FloatPref
import jp.juggler.subwaytooter.pref.impl.IntPref
import jp.juggler.subwaytooter.pref.impl.StringPref
import jp.juggler.subwaytooter.pref.pref
import jp.juggler.subwaytooter.pref.put
import jp.juggler.subwaytooter.pref.remove
import jp.juggler.subwaytooter.table.AcctColor
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.CustomShare
@ -50,7 +52,6 @@ import java.util.*
import java.util.concurrent.TimeUnit
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlin.collections.ArrayList
import kotlin.math.abs
class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnClickListener {
@ -203,7 +204,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli
super.onStop()
// Pull通知チェック間隔を変更したかもしれないのでジョブを再設定する
PollingWorker.onAppSettingStop(this)
restartAllWorker(context = this)
}
override fun onBackPressed() {

View File

@ -29,7 +29,7 @@ import jp.juggler.subwaytooter.api.entity.TootVisibility
import jp.juggler.subwaytooter.column.*
import jp.juggler.subwaytooter.dialog.DlgQuickTootMenu
import jp.juggler.subwaytooter.itemviewholder.StatusButtonsPopup
import jp.juggler.subwaytooter.notification.PollingWorker
import jp.juggler.subwaytooter.notification.checkNotificationImmediateAll
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.pref.PrefI
import jp.juggler.subwaytooter.pref.PrefS
@ -359,7 +359,9 @@ class ActMain : AppCompatActivity(),
updateColumnStrip()
scrollToLastColumn()
PollingWorker.queueUpdateNotification(this)
if (savedInstanceState == null) {
checkNotificationImmediateAll(this)
}
if (savedInstanceState != null) {
sharedIntent2?.let { handleSharedIntent(it) }

View File

@ -171,7 +171,6 @@ class AppState(
// initからプロパティにアクセスする場合、そのプロパティはinitより上で定義されていないとダメっぽい
// そしてその他のメソッドからval プロパティにアクセスする場合、そのプロパティはメソッドより上で初期化されていないとダメっぽい
init {
this.density = context.resources.displayMetrics.density

View File

@ -3,9 +3,12 @@ package jp.juggler.subwaytooter
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import jp.juggler.subwaytooter.notification.PollingWorker
import jp.juggler.subwaytooter.notification.TrackingType
import jp.juggler.subwaytooter.notification.onNotificationDeleted
import jp.juggler.subwaytooter.table.NotificationTracking
import jp.juggler.util.LogCategory
import jp.juggler.util.launchMain
import jp.juggler.util.notEmpty
class EventReceiver : BroadcastReceiver() {
@ -17,14 +20,25 @@ class EventReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
when (val action = intent?.action) {
Intent.ACTION_BOOT_COMPLETED ->
PollingWorker.queueBootCompleted(context)
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
-> {
App1.prepare(context.applicationContext, action)
NotificationTracking.resetPostAll()
}
Intent.ACTION_MY_PACKAGE_REPLACED ->
PollingWorker.queuePackageReplaced(context)
ACTION_NOTIFICATION_DELETE ->
PollingWorker.queueNotificationDeleted(context, intent.data)
ACTION_NOTIFICATION_DELETE -> intent.data?.let { uri ->
val dbId = uri.getQueryParameter("db_id")?.toLongOrNull()
val type = TrackingType.parseStr(uri.getQueryParameter("type"))
val typeName = type.typeName
val id = uri.getQueryParameter("notificationId")?.notEmpty()
log.d("Notification deleted! db_id=$dbId,type=$type,id=$id")
if (dbId != null) {
launchMain {
onNotificationDeleted(dbId, typeName)
}
}
}
else -> log.e("onReceive: unsupported action $action")
}

View File

@ -1,54 +1,78 @@
package jp.juggler.subwaytooter
import android.content.Intent
import android.os.Build
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import jp.juggler.subwaytooter.notification.PollingForegrounder
import jp.juggler.subwaytooter.notification.PollingWorker
import jp.juggler.subwaytooter.notification.ForegroundPollingService
import jp.juggler.subwaytooter.notification.restartAllWorker
import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.table.NotificationCache
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.LogCategory
import java.util.*
class MyFirebaseMessagingService : FirebaseMessagingService() {
companion object {
internal val log = LogCategory("MyFirebaseMessagingService")
private val pushMessageStatus = LinkedList<String>()
// Pushメッセージが処理済みか調べる
private fun isDuplicateMessage(messageId: String) =
synchronized(pushMessageStatus) {
when (pushMessageStatus.contains(messageId)) {
true -> true
else -> {
pushMessageStatus.addFirst(messageId)
while (pushMessageStatus.size > 100) {
pushMessageStatus.removeLast()
}
false
}
}
}
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
val context = this
super.onMessageReceived(remoteMessage)
var tag: String? = null
val data = remoteMessage.data
for ((key, value) in data) {
log.d("onMessageReceived: $key=$value")
val accounts = ArrayList<SavedAccount>()
for ((key, value) in remoteMessage.data) {
log.w("onMessageReceived: $key=$value")
when (key) {
"notification_tag" -> tag = value
"acct" -> tag = "acct<>$value"
"notification_tag" -> {
SavedAccount.loadByTag(context, value).forEach { sa ->
NotificationCache.resetLastLoad(sa.db_id)
accounts.add(sa)
}
}
"acct" -> {
SavedAccount.loadAccountByAcct(context, value)?.let { sa ->
NotificationCache.resetLastLoad(sa.db_id)
accounts.add(sa)
}
}
}
}
val context = applicationContext
val intent = Intent(context, PollingForegrounder::class.java)
if (tag != null) intent.putExtra(PollingWorker.EXTRA_TAG, tag)
if (Build.VERSION.SDK_INT >= 26) {
context.startForegroundService(intent)
} else {
context.startService(intent)
if (accounts.isEmpty()) {
// タグにマッチする情報がなかった場合、全部読み直す
NotificationCache.resetLastLoad()
accounts.addAll(SavedAccount.loadAccountList(context))
}
log.i("accounts.size=${accounts.size}")
accounts.forEach {
ForegroundPollingService.start(this, remoteMessage.messageId, it.db_id)
}
}
override fun onNewToken(token: String) {
try {
log.d("onTokenRefresh: token=$token")
log.w("onTokenRefresh: token=$token")
PrefDevice.from(this).edit().putString(PrefDevice.KEY_DEVICE_TOKEN, token).apply()
PollingWorker.queueFCMTokenUpdated(this)
restartAllWorker(this)
} catch (ex: Throwable) {
log.trace(ex)
log.trace(ex, "onNewToken failed")
}
}
}

View File

@ -12,7 +12,7 @@ import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.dialog.*
import jp.juggler.subwaytooter.notification.PollingWorker
import jp.juggler.subwaytooter.notification.APP_SERVER
import jp.juggler.subwaytooter.pref.*
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.LinkHelper
@ -242,7 +242,7 @@ private fun appServerUnregister(context: Context, account: SavedAccount) {
}&tag=$tag"
.toFormRequestBody()
.toPost()
.url(PollingWorker.APP_SERVER + "/unregister")
.url("$APP_SERVER/unregister")
.build()
)

View File

@ -9,14 +9,13 @@ import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.appsetting.AppDataExporter
import jp.juggler.subwaytooter.column.Column
import jp.juggler.subwaytooter.notification.PollingWorker
import jp.juggler.subwaytooter.notification.cancelAllWorkAndJoin
import jp.juggler.subwaytooter.notification.restartAllWorker
import jp.juggler.util.*
import kotlinx.coroutines.delay
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStreamReader
import java.util.*
import java.util.zip.ZipInputStream
private val log = LogCategory("ActMainImportAppData")
@ -63,11 +62,7 @@ fun ActMain.importAppData(uri: Uri) {
// 通知サービスを止める
setProgressMessage("syncing notification poller…")
PollingWorker.queueAppDataImportBefore(this@importAppData)
while (PollingWorker.mBusyAppDataImportBefore.get()) {
delay(1000L)
log.d("syncing polling task...")
}
cancelAllWorkAndJoin(this@importAppData)
// データを読み込む
setProgressMessage("reading app data...")
@ -138,7 +133,7 @@ fun ActMain.importAppData(uri: Uri) {
updateColumnStrip()
} finally {
// 通知サービスをリスタート
PollingWorker.queueAppDataImportAfter(this@importAppData)
restartAllWorker(this@importAppData)
}
showToast(true, R.string.import_completed_please_restart_app)

View File

@ -6,8 +6,6 @@ import android.net.Uri
import android.os.Build
import androidx.appcompat.app.AlertDialog
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.pref.PrefS
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.action.conversationOtherInstance
import jp.juggler.subwaytooter.action.openActPostImpl
@ -20,8 +18,11 @@ import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.column.startLoading
import jp.juggler.subwaytooter.dialog.pickAccount
import jp.juggler.subwaytooter.notification.PollingWorker
import jp.juggler.subwaytooter.notification.PushSubscriptionHelper
import jp.juggler.subwaytooter.notification.checkNotificationImmediate
import jp.juggler.subwaytooter.notification.recycleClickedNotification
import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.pref.PrefS
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.LinkHelper
import jp.juggler.util.*
@ -172,7 +173,7 @@ private fun ActMain.handleNotificationClick(uri: Uri, dataIdString: String) {
return
}
PollingWorker.queueNotificationClicked(this, uri)
recycleClickedNotification(this, uri)
val columnList = appState.columnList
val column = columnList.firstOrNull {
@ -370,7 +371,7 @@ fun ActMain.afterAccountVerify(
private fun ActMain.afterAccessTokenUpdate(
ta: TootAccount,
sa: SavedAccount,
tokenInfo: JsonObject?
tokenInfo: JsonObject?,
): Boolean {
if (sa.username != ta.username) {
showToast(true, R.string.user_name_not_match)
@ -390,7 +391,7 @@ private fun ActMain.afterAccessTokenUpdate(
// 通知の更新が必要かもしれない
PushSubscriptionHelper.clearLastCheck(sa)
PollingWorker.queueUpdateNotification(applicationContext)
checkNotificationImmediate(applicationContext, sa.db_id)
showToast(false, R.string.access_token_updated_for, sa.acct.pretty)
return true
@ -454,7 +455,7 @@ private fun ActMain.afterAccountAdd(
}
// 通知の更新が必要かもしれない
PollingWorker.queueUpdateNotification(applicationContext)
checkNotificationImmediate(applicationContext, account.db_id)
showToast(false, R.string.account_confirmed)
return true
}

View File

@ -140,7 +140,7 @@ fun ActPost.restoreDraft(draft: JsonObject) {
var targetAccount: SavedAccount? = null
runWithProgress("restore from draft", doInBackground = { progress ->
fun isTaskCancelled() = !this.coroutineContext.isActive
fun isTaskCancelled() = !coroutineContext.isActive
var content = draft.string(DRAFT_CONTENT) ?: ""
val tmpAttachmentList =

View File

@ -169,7 +169,6 @@ class TootApiClient(
httpClient.onCallCreated = value
}
@Suppress("unused")
internal suspend fun isApiCancelled() = callback.isApiCancelled()
suspend fun publishApiProgress(s: String) {

View File

@ -3,16 +3,12 @@ package jp.juggler.subwaytooter.column
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.columnviewholder.onListListUpdated
import jp.juggler.subwaytooter.notification.PollingWorker
import jp.juggler.subwaytooter.notification.onNotificationCleared
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.BucketList
import jp.juggler.subwaytooter.util.matchHost
import jp.juggler.util.AdapterChange
import jp.juggler.util.AdapterChangeType
import jp.juggler.util.JsonObject
import jp.juggler.util.LogCategory
import java.util.*
import kotlin.collections.ArrayList
import jp.juggler.util.*
import kotlinx.coroutines.launch
import kotlin.collections.set
private val log = LogCategory("ColumnActions")
@ -195,7 +191,13 @@ fun Column.removeNotifications() {
duplicateMap.clear()
fireShowContent(reason = "removeNotifications", reset = true)
PollingWorker.queueNotificationCleared(context, accessInfo.db_id)
EndlessScope.launch {
try {
onNotificationCleared(context, accessInfo.db_id)
} catch (ex: Throwable) {
log.trace(ex, "onNotificationCleared failed.")
}
}
}
// 通知を削除した後に呼ばれる

View File

@ -1,11 +1,12 @@
package jp.juggler.subwaytooter.column
import android.os.SystemClock
import jp.juggler.subwaytooter.*
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.DedupMode
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.columnviewholder.*
import jp.juggler.subwaytooter.notification.PollingWorker
import jp.juggler.subwaytooter.notification.injectData
import jp.juggler.subwaytooter.streaming.StreamManager
import jp.juggler.subwaytooter.streaming.StreamStatus
import jp.juggler.subwaytooter.util.ScrollPosition
@ -180,7 +181,7 @@ fun Column.mergeStreamingMessage() {
private fun Column.injectToPollingWorker(listNew: ArrayList<TimelineItem>) {
if (!isNotificationColumn) return
listNew.mapNotNull { it as? TootNotification }.notEmpty()
?.let { PollingWorker.injectData(context, accessInfo, it) }
?.let { injectData(context, accessInfo, it) }
}
private fun Column.sendToSpeech(listNew: ArrayList<TimelineItem>) {
@ -203,7 +204,12 @@ private fun Column.addGapAfterStreaming(listNew: ArrayList<TimelineItem>, newIdM
}
}
private fun Column.scrollAfterStreaming(added: Int, holderSp: ScrollPosition?, restoreIdx: Int, restoreY: Int) {
private fun Column.scrollAfterStreaming(
added: Int,
holderSp: ScrollPosition?,
restoreIdx: Int,
restoreY: Int,
) {
val holder = viewHolder
if (holder == null) {
val scrollSave = this.scrollSave

View File

@ -1,13 +1,12 @@
package jp.juggler.subwaytooter.column
import android.os.SystemClock
import jp.juggler.subwaytooter.*
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.finder.*
import jp.juggler.subwaytooter.columnviewholder.getListItemOffset
import jp.juggler.subwaytooter.columnviewholder.setListItemTop
import jp.juggler.subwaytooter.notification.PollingWorker
import jp.juggler.subwaytooter.notification.injectData
import jp.juggler.subwaytooter.pref.PrefI
import jp.juggler.util.*
import java.lang.StringBuilder
@ -741,7 +740,7 @@ class ColumnTask_Gap(
}
}.also {
listTmp?.mapNotNull { it as? TootNotification }.notEmpty()?.let {
PollingWorker.injectData(context, accessInfo, it)
injectData(context, accessInfo, it)
}
}
}

View File

@ -1,17 +1,16 @@
package jp.juggler.subwaytooter.column
import android.os.SystemClock
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.finder.*
import jp.juggler.subwaytooter.columnviewholder.scrollToTop
import jp.juggler.subwaytooter.notification.PollingWorker
import jp.juggler.subwaytooter.notification.injectData
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.util.OpenSticker
import jp.juggler.util.*
import java.util.*
import kotlin.collections.ArrayList
@Suppress("ClassNaming")
class ColumnTask_Loading(
@ -105,7 +104,8 @@ class ColumnTask_Loading(
ColumnType.SEARCH -> listTmp
// 編集履歴は投稿日時で重複排除する
ColumnType.STATUS_HISTORY -> column.duplicateMap.filterDuplicateByCreatedAt(listTmp)
ColumnType.STATUS_HISTORY -> column.duplicateMap.filterDuplicateByCreatedAt(
listTmp)
// 他のカラムは重複排除してから追加
else -> column.duplicateMap.filterDuplicate(listTmp)
@ -648,7 +648,7 @@ class ColumnTask_Loading(
}
}.also {
listTmp?.mapNotNull { it as? TootNotification }?.let {
PollingWorker.injectData(context, accessInfo, it)
injectData(context, accessInfo, it)
}
}
}

View File

@ -0,0 +1,82 @@
package jp.juggler.subwaytooter.notification
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.R
object CheckerNotification {
private var lastMessage: String? = null
suspend fun showMessage(
context: Context,
text: String,
shower: suspend (Notification) -> Unit,
) {
// テキストが変化していないなら更新しない
if (text.isEmpty() || text == lastMessage) return
lastMessage = text
// // This PendingIntent can be used to cancel the worker
// val cancel = context.getString(R.string.cancel)
// val cancelIntent = WorkManager.getInstance(context)
// .createCancelPendingIntent(id)
val builder = if (Build.VERSION.SDK_INT >= 26) {
// Android 8 から、通知のスタイルはユーザが管理することになった
// NotificationChannel を端末に登録しておけば、チャネルごとに管理画面が作られる
// The user-visible description of the channel.
val channel = NotificationHelper.createNotificationChannel(
context,
"PollingForegrounder",
"real-time message notifier",
null,
NotificationManagerCompat.IMPORTANCE_LOW
)
NotificationCompat.Builder(context, channel.id)
} else {
NotificationCompat.Builder(context, "not_used")
}
// 通知タップ時のPendingIntent
val clickIntent = Intent(context, ActMain::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val clickPi = PendingIntent.getActivity(
context,
2,
clickIntent,
PendingIntent.FLAG_UPDATE_CURRENT or when {
Build.VERSION.SDK_INT >= 23 -> PendingIntent.FLAG_IMMUTABLE
else -> 0
}
)
// ここは常に白テーマのアイコンと色を使う
builder
.setContentIntent(clickPi)
.setAutoCancel(false)
.setOngoing(true)
.setSmallIcon(R.drawable.ic_notification)
.setColor(ContextCompat.getColor(context, R.color.Light_colorAccent))
.setWhen(System.currentTimeMillis())
.setContentTitle(context.getString(R.string.loading_notification_title))
.setContentText(text)
// .addAction(android.R.drawable.ic_delete, cancel, cancelIntent)
// Android 7.0 ではグループを指定しないと勝手に通知が束ねられてしまう。
// 束ねられた通知をタップしても pi_click が実行されないので困るため、
// アカウント別にグループキーを設定する
builder.setGroup(context.packageName + ":PollingForegrounder")
shower(builder.build())
}
}

View File

@ -0,0 +1,129 @@
package jp.juggler.subwaytooter.notification
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.net.ConnectivityManager
import android.net.wifi.WifiManager
import android.os.Build
import android.os.PowerManager
import jp.juggler.subwaytooter.App1
import jp.juggler.util.systemService
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
class CheckerWakeLocks(contextArg: Context) {
companion object {
private var checkerWakeLocksNullable: CheckerWakeLocks? = null
fun checkerWakeLocks(context: Context): CheckerWakeLocks {
// double-check before/after lock
checkerWakeLocksNullable?.let { return it }
return synchronized(this) {
checkerWakeLocksNullable
?: CheckerWakeLocks(context.applicationContext)
.also { checkerWakeLocksNullable = it }
}
}
}
// クラッシュレポートによると App1.onCreate より前にここを通る場合がある
// データベースへアクセスできるようにする
val appState = App1.prepare(contextArg, "PollingNotificationChecker")
val connectivityManager: ConnectivityManager by lazy {
systemService(contextArg)
?: error("missing ConnectivityManager system service")
}
val notificationManager: NotificationManager by lazy {
systemService(contextArg)
?: error("missing NotificationManager system service")
}
private val powerManager: PowerManager by lazy {
systemService(contextArg)
?: error("missing PowerManager system service")
}
private val wifiManager: WifiManager by lazy {
// WifiManagerの取得時はgetApplicationContext を使わないとlintに怒られる
systemService(contextArg.applicationContext)
?: error("missing WifiManager system service")
}
private val powerLock: PowerManager.WakeLock by lazy {
powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
PollingChecker::class.java.name
).apply { setReferenceCounted(false) }
}
private val wifiLock: WifiManager.WifiLock by lazy {
if (Build.VERSION.SDK_INT >= 29) {
wifiManager.createWifiLock(
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
PollingChecker::class.java.name
)
} else {
@Suppress("DEPRECATION")
wifiManager.createWifiLock(PollingChecker::class.java.name)
}.apply { setReferenceCounted(false) }
}
@SuppressLint("WakelockTimeout")
fun acquireWakeLocks() {
PollingChecker.log.d("acquire power lock...")
try {
if (!powerLock.isHeld) {
powerLock.acquire()
}
} catch (ex: Throwable) {
PollingChecker.log.trace(ex)
}
try {
if (!wifiLock.isHeld) {
wifiLock.acquire()
}
} catch (ex: Throwable) {
PollingChecker.log.trace(ex)
}
}
fun releasePowerLocks() {
PollingChecker.log.d("release power lock...")
try {
if (powerLock.isHeld) {
powerLock.release()
}
} catch (ex: Throwable) {
PollingChecker.log.trace(ex)
}
try {
if (wifiLock.isHeld) {
wifiLock.release()
}
} catch (ex: Throwable) {
PollingChecker.log.trace(ex)
}
}
/**
* ネットワーク接続があるか一定時間まちタイムアウトしたら例外を投げる
*/
suspend fun checkConnection() {
var connectionState: String? = null
try {
withTimeout(10000L) {
while (true) {
connectionState = appState.networkTracker.connectionState
?: break // null if connected
delay(333L)
}
}
} catch (ignored: TimeoutCancellationException) {
error("network state timeout. $connectionState")
}
}
}

View File

@ -0,0 +1,113 @@
package jp.juggler.subwaytooter.notification
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.os.SystemClock
import androidx.core.content.ContextCompat
import jp.juggler.subwaytooter.notification.CheckerWakeLocks.Companion.checkerWakeLocks
import jp.juggler.util.EndlessScope
import jp.juggler.util.LogCategory
import jp.juggler.util.launchMain
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
class ForegroundPollingService : Service() {
companion object {
private val log = LogCategory("ForegroundPollingService")
private const val NOTIFICATION_ID_FOREGROUND_POLLING = 4
private const val EXTRA_ACCOUNT_DB_ID = "accountDbId"
private const val EXTRA_MESSAGE_ID = "messageId"
fun start(
context: Context,
messageId: String?,
dbId: Long,
) {
val intent = Intent(context, ForegroundPollingService::class.java).apply {
putExtra(EXTRA_ACCOUNT_DB_ID, dbId)
putExtra(EXTRA_MESSAGE_ID, messageId)
}
ContextCompat.startForegroundService(context, intent)
}
}
private class Item(
val accountDbId: Long,
var lastRequired: Long = 0L,
var lastHandled: Long = 0L,
var lastStartId: Int = 0,
)
private val map = HashMap<Long, Item>()
private val channel = Channel<Long>()
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
log.i("onCreate")
super.onCreate()
checkerWakeLocks(this).acquireWakeLocks()
}
override fun onDestroy() {
log.i("onDestroy")
super.onDestroy()
checkerWakeLocks(this).releasePowerLocks()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val accountDbId = intent?.getLongExtra(EXTRA_ACCOUNT_DB_ID, -1L) ?: -1L
val now = SystemClock.elapsedRealtime()
log.i("onStartCommand accountDbId=$accountDbId")
synchronized(map) {
map.getOrPut(accountDbId) {
Item(accountDbId = accountDbId)
}.apply {
lastRequired = now
lastStartId = startId
}
}
launchMain { channel.send(now) }
return START_NOT_STICKY
}
init {
EndlessScope.launch {
while (true) {
try {
channel.receive()
val target = synchronized(map) {
map.values
.filter { it.lastRequired > it.lastHandled }
.minByOrNull { it.lastRequired }
}
if (target != null) {
check(target.accountDbId)
stopSelf(target.lastStartId)
}
} catch (ex: Throwable) {
log.trace(ex)
}
}
}
}
private suspend fun check(accountDbId: Long) {
try {
PollingChecker(
context = this@ForegroundPollingService,
accountDbId = accountDbId
) { showMessage(it) }.check()
} catch (ex: Throwable) {
log.trace(ex)
}
}
private suspend fun showMessage(text: String) {
CheckerNotification.showMessage(this, text) {
startForeground(NOTIFICATION_ID_FOREGROUND_POLLING, it)
}
}
}

View File

@ -1,12 +0,0 @@
package jp.juggler.subwaytooter.notification
import jp.juggler.subwaytooter.api.entity.TootNotification
import java.util.ArrayList
class InjectData(
var accountDbId: Long = 0,
source: List<TootNotification>,
) {
// copy to holder
val list = ArrayList(source)
}

View File

@ -1,3 +0,0 @@
package jp.juggler.subwaytooter.notification
class JobCancelledException : RuntimeException("job is cancelled.")

View File

@ -1,19 +0,0 @@
package jp.juggler.subwaytooter.notification
// ジョブID
enum class JobId(val int: Int) {
// polling notifications periodically
Polling(1),
// task added by application
Task(2),
// invoked by push messaging
Push(3),
;
companion object {
fun from(int: Int) = values().firstOrNull { it.int == int }
}
}

View File

@ -1,140 +0,0 @@
package jp.juggler.subwaytooter.notification
import android.app.job.JobParameters
import android.app.job.JobService
import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.subwaytooter.table.*
import jp.juggler.util.JsonObject
import jp.juggler.util.LogCategory
import jp.juggler.util.WordTrieTree
import jp.juggler.util.runOnMainLooper
import kotlinx.coroutines.*
import okhttp3.Call
import java.lang.ref.WeakReference
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
/*
JobSchedulerに登録する & アプリ内部でも保持するジョブのリスト
アプリ内部で保持するのは主にサービス完了通知のせい
* */
class JobItem(
val jobId: JobId,
val jobParams: JobParameters? = null,
val refJobService: WeakReference<JobService>? = null,
) {
companion object {
private val log = LogCategory("JobItem")
private var workerStatus = ""
set(value) {
field = value
PollingWorker.workerStatus = value
}
}
val abJobCancelled = AtomicBoolean()
val abReschedule = AtomicBoolean()
val abWorkerAttached = AtomicBoolean()
val bPollingRequired = AtomicBoolean(false)
private lateinit var mutedApp: HashSet<String>
lateinit var mutedWord: WordTrieTree
lateinit var favMuteSet: HashSet<Acct>
var bPollingComplete = false
var installId: String? = null
var currentCall: WeakReference<Call>? = null
val isJobCancelled: Boolean
get() = abJobCancelled.get()
// 通知データインジェクションを行ったアカウント
val injectedAccounts = HashSet<Long>()
private var pollingWorker: PollingWorker? = null
fun cancel(bReschedule: Boolean) {
abJobCancelled.set(true)
abReschedule.set(bReschedule)
currentCall?.get()?.cancel()
pollingWorker?.notifyWorker()
}
suspend fun run(pollingWorker: PollingWorker) = coroutineScope {
this@JobItem.pollingWorker = pollingWorker
try {
log.d("(JobItem.run jobId=$jobId")
workerStatus = "check network status.."
var connectionState: String? = null
try {
withTimeout(10000L) {
while (true) {
if (isJobCancelled) throw JobCancelledException()
connectionState = pollingWorker.appState
.networkTracker.connectionState
?: break // null if connected
delay(333L)
}
}
} catch (ignored: TimeoutCancellationException) {
log.d("network state timeout. $connectionState")
}
mutedApp = MutedApp.nameSet
mutedWord = MutedWord.nameSet
favMuteSet = FavMute.acctSet
// タスクがあれば処理する
while (true) {
if (isJobCancelled) throw JobCancelledException()
val data = PollingWorker.task_list.next(pollingWorker.context) ?: break
val taskIdInt = data.optInt(PollingWorker.EXTRA_TASK_ID, -1)
val taskId = TaskId.from(taskIdInt)
if (taskId == null) {
log.e("JobItem.run(): unknown taskId $taskIdInt")
continue
}
// アプリデータのインポート処理が開始したらジョブを全て削除して処理を中断する
// アプリデータのインポート処理中は他のジョブは実行されない。
when {
!pollingWorker.onStartTask(taskId) -> return@coroutineScope
else -> TaskRunner(pollingWorker, this@JobItem, taskId, data).runTask()
}
}
if (!isJobCancelled && !bPollingComplete && JobId.Polling == jobId) {
// タスクがなかった場合でも定期実行ジョブからの実行ならポーリングを行う
TaskRunner(pollingWorker, this@JobItem, TaskId.Polling, JsonObject()).runTask()
}
if (!bPollingComplete || isJobCancelled || !bPollingRequired.get()) {
log.w("pollingComplete=$bPollingComplete,isJobCancelled=$isJobCancelled,bPollingRequired=${bPollingRequired.get()}")
}
if (!isJobCancelled && bPollingComplete) {
// ポーリングが完了した
pollingWorker.onPollingComplete(bPollingRequired.get())
}
// no log for normal case
} catch (ignored: JobCancelledException) {
log.w("job execution cancelled.")
} catch (ex: Throwable) {
log.trace(ex)
log.e(ex, "job execution failed.")
}
log.d(")JobItem.run jobId=$jobId, cancel=$isJobCancelled")
// メインスレッドで後処理を行う
runOnMainLooper {
if (isJobCancelled) return@runOnMainLooper
pollingWorker.onJobComplete(this@JobItem)
}
}
}

View File

@ -0,0 +1,232 @@
package jp.juggler.subwaytooter.notification
import android.annotation.TargetApi
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import jp.juggler.subwaytooter.ActCallback
import jp.juggler.subwaytooter.EventReceiver
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.table.AcctColor
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.*
object MessageNotification {
private val log = LogCategory("MessageNotification")
private const val NOTIFICATION_ID_MESSAGE = 1
const val TRACKING_NAME_DEFAULT = ""
const val TRACKING_NAME_REPLY = "reply"
/**
* メッセージ通知を消す
*/
fun NotificationManager.removeMessageNotification(id: String?, tag: String) {
when (id) {
null -> cancel(tag, NOTIFICATION_ID_MESSAGE)
else -> cancel("$tag/$id", NOTIFICATION_ID_MESSAGE)
}
}
/** メッセージ通知をたくさん消す
*
*/
fun NotificationManager.removeMessageNotification(account: SavedAccount, tag: String) {
if (Build.VERSION.SDK_INT >= 23 && PrefB.bpDivideNotification()) {
activeNotifications?.filterNotNull()?.filter {
it.id == NOTIFICATION_ID_MESSAGE && it.tag.startsWith("$tag/")
}?.forEach {
log.d("cancel: ${it.tag} context=${account.acct.pretty} $tag")
cancel(it.tag, NOTIFICATION_ID_MESSAGE)
}
} else {
cancel(tag, NOTIFICATION_ID_MESSAGE)
}
}
/**
* 表示中のメッセージ通知の一覧
*/
@TargetApi(23)
fun NotificationManager.getMessageNotifications(tag: String) =
activeNotifications?.filterNotNull()?.filter {
it.id == NOTIFICATION_ID_MESSAGE && it.tag.startsWith("$tag/")
}?.map { Pair(it.tag, it) }?.toMutableMap() ?: mutableMapOf()
fun NotificationManager.showMessageNotification(
context: Context,
account: SavedAccount,
trackingName: String,
trackingType: TrackingType,
notificationTag: String,
notificationId: String? = null,
setContent: (builder: NotificationCompat.Builder) -> Unit,
) {
log.d("showNotification[${account.acct.pretty}] creating notification(1)")
val builder = if (Build.VERSION.SDK_INT >= 26) {
// Android 8 から、通知のスタイルはユーザが管理することになった
// NotificationChannel を端末に登録しておけば、チャネルごとに管理画面が作られる
val channel = createMessageNotificationChannel(
context,
account,
trackingName
)
NotificationCompat.Builder(context, channel.id)
} else {
NotificationCompat.Builder(context, "not_used")
}
builder.apply {
val params = listOf(
"db_id" to account.db_id.toString(),
"type" to trackingType.str,
"notificationId" to notificationId
).mapNotNull {
when (val second = it.second) {
null -> null
else -> "${it.first.encodePercent()}=${second.encodePercent()}"
}
}.joinToString("&")
val flag = PendingIntent.FLAG_UPDATE_CURRENT or
(if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
PendingIntent.getActivity(
context,
257,
Intent(context, ActCallback::class.java).apply {
data = "subwaytooter://notification_click/?$params".toUri()
// FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY を付与してはいけない
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
flag
)?.let { setContentIntent(it) }
PendingIntent.getBroadcast(
context,
257,
Intent(context, EventReceiver::class.java).apply {
action = EventReceiver.ACTION_NOTIFICATION_DELETE
data = "subwaytooter://notification_delete/?$params".toUri()
},
flag
)?.let { setDeleteIntent(it) }
setAutoCancel(true)
// 常に白テーマのアイコンを使う
setSmallIcon(R.drawable.ic_notification)
// 常に白テーマの色を使う
builder.color = ContextCompat.getColor(context, R.color.Light_colorAccent)
// Android 7.0 ではグループを指定しないと勝手に通知が束ねられてしまう。
// 束ねられた通知をタップしても pi_click が実行されないので困るため、
// アカウント別にグループキーを設定する
setGroup(context.packageName + ":" + account.acct.ascii)
}
log.d("showNotification[${account.acct.pretty}] creating notification(3)")
setContent(builder)
log.d("showNotification[${account.acct.pretty}] set notification...")
notify(
notificationTag,
NOTIFICATION_ID_MESSAGE,
builder.build()
)
}
// Android 8 未満ではチャネルではなく通知に個別にスタイルを設定する
@TargetApi(25)
fun setNotificationSound25(
account: SavedAccount,
builder: NotificationCompat.Builder,
item: NotificationData,
) {
var iv = 0
if (PrefB.bpNotificationSound()) {
var soundUri: Uri? = null
try {
val whoAcct = account.getFullAcct(item.notification.account)
soundUri = AcctColor.getNotificationSound(whoAcct).mayUri()
} catch (ex: Throwable) {
PollingChecker.log.trace(ex)
}
if (soundUri == null) {
soundUri = account.sound_uri.mayUri()
}
var bSoundSet = false
if (soundUri != null) {
try {
builder.setSound(soundUri)
bSoundSet = true
} catch (ex: Throwable) {
PollingChecker.log.trace(ex)
}
}
if (!bSoundSet) {
iv = iv or NotificationCompat.DEFAULT_SOUND
}
}
if (PrefB.bpNotificationVibration()) {
iv = iv or NotificationCompat.DEFAULT_VIBRATE
}
if (PrefB.bpNotificationLED()) {
iv = iv or NotificationCompat.DEFAULT_LIGHTS
}
builder.setDefaults(iv)
}
@TargetApi(26)
fun createMessageNotificationChannel(
context: Context,
account: SavedAccount,
trackingName: String,
) = when (trackingName) {
"" -> NotificationHelper.createNotificationChannel(
context,
account.acct.ascii, // id
account.acct.pretty, // name
context.getString(R.string.notification_channel_description, account.acct.pretty),
NotificationManager.IMPORTANCE_DEFAULT // : NotificationManager.IMPORTANCE_LOW;
)
else -> NotificationHelper.createNotificationChannel(
context,
"${account.acct.ascii}/$trackingName", // id
"${account.acct.pretty}/$trackingName", // name
context.getString(R.string.notification_channel_description, account.acct.pretty),
NotificationManager.IMPORTANCE_DEFAULT // : NotificationManager.IMPORTANCE_LOW;
)
}
fun openNotificationChannelSetting(
context: Context,
account: SavedAccount,
trackingName: String,
) {
if (Build.VERSION.SDK_INT >= 26) {
val channel = createMessageNotificationChannel(context, account, trackingName)
val intent = Intent("android.settings.CHANNEL_NOTIFICATION_SETTINGS")
intent.putExtra(Settings.EXTRA_CHANNEL_ID, channel.id)
intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
context.startActivity(intent)
}
}
}

View File

@ -4,43 +4,12 @@ import android.annotation.TargetApi
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.LogCategory
object NotificationHelper {
private val log = LogCategory("NotificationHelper")
internal const val TRACKING_NAME_DEFAULT = ""
internal const val TRACKING_NAME_REPLY = "reply"
@TargetApi(26)
fun createNotificationChannel(
context: Context,
account: SavedAccount,
trackingName: String
) = when (trackingName) {
"" -> createNotificationChannel(
context,
account.acct.ascii, // id
account.acct.pretty, // name
context.getString(R.string.notification_channel_description, account.acct.pretty),
NotificationManager.IMPORTANCE_DEFAULT // : NotificationManager.IMPORTANCE_LOW;
)
else -> createNotificationChannel(
context,
"${account.acct.ascii}/$trackingName", // id
"${account.acct.pretty}/$trackingName", // name
context.getString(R.string.notification_channel_description, account.acct.pretty),
NotificationManager.IMPORTANCE_DEFAULT // : NotificationManager.IMPORTANCE_LOW;
)
}
@TargetApi(26)
fun createNotificationChannel(
context: Context,
@ -69,18 +38,4 @@ object NotificationHelper {
notification_manager.createNotificationChannel(channel)
return channel
}
fun openNotificationChannelSetting(
context: Context,
account: SavedAccount,
trackingName: String
) {
if (Build.VERSION.SDK_INT >= 26) {
val channel = createNotificationChannel(context, account, trackingName)
val intent = Intent("android.settings.CHANNEL_NOTIFICATION_SETTINGS")
intent.putExtra(Settings.EXTRA_CHANNEL_ID, channel.id)
intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
context.startActivity(intent)
}
}
}

View File

@ -0,0 +1,554 @@
package jp.juggler.subwaytooter.notification
import android.annotation.TargetApi
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.work.ExistingPeriodicWorkPolicy
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootApiCallback
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.notification.CheckerWakeLocks.Companion.checkerWakeLocks
import jp.juggler.subwaytooter.notification.MessageNotification.getMessageNotifications
import jp.juggler.subwaytooter.notification.MessageNotification.removeMessageNotification
import jp.juggler.subwaytooter.notification.MessageNotification.setNotificationSound25
import jp.juggler.subwaytooter.notification.MessageNotification.showMessageNotification
import jp.juggler.subwaytooter.notification.ServerTimeoutNotification.createServerTimeoutNotification
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.table.*
import jp.juggler.util.JsonObject
import jp.juggler.util.LogCategory
import jp.juggler.util.notEmpty
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.yield
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.coroutineContext
import kotlin.math.min
/**
* 1アカウント,1回ごとの通知チェック処理
*/
class PollingChecker(
val context: Context,
private val accountDbId: Long,
private var injectData: List<TootNotification> = emptyList(),
private val canStopWakeLock: () -> Boolean = { true },
val progress: suspend (String) -> Unit,
) {
companion object {
val log = LogCategory("PollingNotificationChecker")
val commonMutex = Mutex()
private val mutexMap = ConcurrentHashMap<Long, Mutex>()
fun accountMutex(accountDbId: Long): Mutex = mutexMap.getOrPut(accountDbId) { Mutex() }
}
class PollingCommonPart(
val installId: String,
val favMuteSet: HashSet<Acct>,
)
private val wakelocks = checkerWakeLocks(context)
private lateinit var cache: NotificationCache
private val client = TootApiClient(
context,
callback = object : TootApiCallback {
override suspend fun isApiCancelled(): Boolean {
if (!coroutineContext.isActive) {
log.w("coroutineContext is not active!")
}
return !coroutineContext.isActive
}
}
)
private fun NotificationData.getNotificationLine(): String {
val name = when (PrefB.bpShowAcctInSystemNotification()) {
false -> notification.accountRef?.decoded_display_name
true -> {
val acctPretty = notification.accountRef?.get()?.acct?.pretty
if (acctPretty?.isNotEmpty() == true) {
"@$acctPretty"
} else {
null
}
}
} ?: "?"
return "- " + when (notification.type) {
TootNotification.TYPE_MENTION,
TootNotification.TYPE_REPLY,
-> context.getString(R.string.display_name_replied_by, name)
TootNotification.TYPE_RENOTE,
TootNotification.TYPE_REBLOG,
-> context.getString(R.string.display_name_boosted_by, name)
TootNotification.TYPE_QUOTE,
-> context.getString(R.string.display_name_quoted_by, name)
TootNotification.TYPE_STATUS,
-> context.getString(R.string.display_name_posted_by, name)
TootNotification.TYPE_UPDATE,
-> context.getString(R.string.display_name_updates_post, name)
TootNotification.TYPE_STATUS_REFERENCE,
-> context.getString(R.string.display_name_references_post, name)
TootNotification.TYPE_FOLLOW,
-> context.getString(R.string.display_name_followed_by, name)
TootNotification.TYPE_UNFOLLOW,
-> context.getString(R.string.display_name_unfollowed_by, name)
TootNotification.TYPE_ADMIN_SIGNUP,
-> context.getString(R.string.display_name_signed_up, name)
TootNotification.TYPE_FAVOURITE,
-> context.getString(R.string.display_name_favourited_by, name)
TootNotification.TYPE_EMOJI_REACTION_PLEROMA,
TootNotification.TYPE_EMOJI_REACTION,
TootNotification.TYPE_REACTION,
-> context.getString(R.string.display_name_reaction_by, name)
TootNotification.TYPE_VOTE,
TootNotification.TYPE_POLL_VOTE_MISSKEY,
-> context.getString(R.string.display_name_voted_by, name)
TootNotification.TYPE_FOLLOW_REQUEST,
TootNotification.TYPE_FOLLOW_REQUEST_MISSKEY,
-> context.getString(R.string.display_name_follow_request_by, name)
TootNotification.TYPE_FOLLOW_REQUEST_ACCEPTED_MISSKEY,
-> context.getString(R.string.display_name_follow_request_accepted_by, name)
TootNotification.TYPE_POLL,
-> context.getString(R.string.end_of_polling_from, name)
else -> "?"
}
}
private fun createPolicyFilter(
account: SavedAccount,
): (TootNotification) -> Boolean = when (account.push_policy) {
"followed" -> { it ->
val who = it.account
when {
who == null -> true
account.isMe(who) -> true
else -> UserRelation.load(account.db_id, who.id).following
}
}
"follower" -> { it ->
val who = it.account
when {
it.type == TootNotification.TYPE_FOLLOW ||
it.type == TootNotification.TYPE_FOLLOW_REQUEST -> true
who == null -> true
account.isMe(who) -> true
else -> UserRelation.load(account.db_id, who.id).followed_by
}
}
"none" -> { _ -> false }
else -> { _ -> true }
}
suspend fun check() = coroutineScope {
val bPollingRequired = AtomicBoolean(false)
val deviceToken = loadFirebaseMessagingToken(context)
loadInstallId(context, deviceToken, progress)
val favMuteSet = commonMutex.withLock {
FavMute.acctSet
}
commonMutex.withLock {
// グローバル変数の暖気
TootStatus.muted_app = MutedApp.nameSet
TootStatus.muted_word = MutedWord.nameSet
}
accountMutex(accountDbId).withLock {
val account = SavedAccount.loadAccount(context, accountDbId)
// 疑似アカウントはチェック対象外
// 未確認アカウントはチェック対象外
if (account == null || account.isPseudo || !account.isConfirmed) {
PollingWorker.cancelPolling(context, accountDbId)
return@coroutineScope
}
progress("[${account.acct.pretty}] check start.")
client.account = account
progress("[${account.acct.pretty}] waiting network connection…")
wakelocks.checkConnection()
progress("[${account.acct.pretty}] check push subscription…")
val wps = PushSubscriptionHelper(context, account)
if (wps.flags != 0) {
bPollingRequired.set(true)
val (instance, instanceResult) = TootInstance.get(client)
if (instance == null) {
if (instanceResult != null) {
log.e("${instanceResult.error} ${instanceResult.requestInfo}".trim())
account.updateNotificationError("${instanceResult.error} ${instanceResult.requestInfo}".trim())
}
return@coroutineScope
}
}
yield()
wps.updateSubscription(client)
?: return@coroutineScope // cancelled.
val wpsLog = wps.logString
if (wpsLog.isNotEmpty()) {
log.d("PushSubscriptionHelper: ${account.acct.pretty} $wpsLog")
}
yield()
if (wps.flags == 0) {
if (account.lastNotificationError != null) {
account.updateNotificationError(null)
}
PollingWorker.cancelPolling(context, accountDbId)
return@coroutineScope
}
PollingWorker.enqueuePolling(
context,
accountDbId,
// Worker以外から起動された場合、Workerが未登録の場合だけ更新する
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP
)
yield()
injectData.notEmpty()?.let { list ->
log.d("processInjectedData ${account.acct} size=${list.size}")
NotificationCache(accountDbId).apply {
load()
inject(account, list)
}
}
yield()
var isTimeout = false
val onError: (TootApiResult) -> Unit = { result ->
if (result.error?.contains("Timeout") == true &&
!account.dont_show_timeout
) {
isTimeout = true
}
}
cache = NotificationCache(account.db_id).apply {
load()
requestAsync(
client,
account,
wps.flags,
onError = onError,
)
}
yield()
if (PrefB.bpSeparateReplyNotificationGroup()) {
var tr = TrackingRunner(
account = account,
favMuteSet = favMuteSet,
trackingType = TrackingType.NotReply,
trackingName = MessageNotification.TRACKING_NAME_DEFAULT
)
tr.checkAccount()
yield()
tr.updateNotification()
//
tr = TrackingRunner(
account = account,
favMuteSet = favMuteSet,
trackingType = TrackingType.Reply,
trackingName = MessageNotification.TRACKING_NAME_REPLY
)
tr.checkAccount()
yield()
tr.updateNotification()
} else {
val tr = TrackingRunner(
account = account,
favMuteSet = favMuteSet,
trackingType = TrackingType.All,
trackingName = MessageNotification.TRACKING_NAME_DEFAULT
)
tr.checkAccount()
yield()
tr.updateNotification()
}
if (isTimeout) {
wakelocks.notificationManager.createServerTimeoutNotification(context, account)
}
progress("[${account.acct.pretty}] check end.")
}
}
inner class TrackingRunner(
val account: SavedAccount,
val favMuteSet: HashSet<Acct>,
var trackingType: TrackingType = TrackingType.All,
var trackingName: String = "",
) {
private val notificationManager = wakelocks.notificationManager
private lateinit var nr: NotificationTracking
private val duplicateCheck = HashSet<EntityId>()
private val dstListData = LinkedList<NotificationData>()
val policyFilter = createPolicyFilter(account)
private val parser = TootParser(context, account)
suspend fun checkAccount() {
this.nr =
NotificationTracking.load(account.acct.pretty, account.db_id, trackingName)
fun JsonObject.isMention() =
when (NotificationCache.parseNotificationType(account, this)) {
TootNotification.TYPE_REPLY, TootNotification.TYPE_MENTION -> true
else -> false
}
val jsonList = when (trackingType) {
TrackingType.All -> cache.data
TrackingType.Reply -> cache.data.filter { it.isMention() }
TrackingType.NotReply -> cache.data.filter { !it.isMention() }
}
// 新しい順に並んでいる。先頭から10件までを処理する。ただし処理順序は古い方から
val size = min(10, jsonList.size)
for (i in (0 until size).reversed()) {
yield()
updateSub(jsonList[i])
}
yield()
// 種別チェックより先に、cache中の最新のIDを「最後に表示した通知」に指定する
// nid_show は通知タップ時に参照されるので、通知を表示する際は必ず更新・保存する必要がある
// 種別チェックより優先する
val latestId = cache.filterLatestId(account) {
when (trackingType) {
TrackingType.Reply -> it.isMention()
TrackingType.NotReply -> !it.isMention()
else -> true
}
}
if (latestId != null) nr.nid_show = latestId
nr.save(account.acct.pretty)
}
private fun updateSub(src: JsonObject) {
val id = NotificationCache.getEntityOrderId(account, src)
if (id.isDefault || duplicateCheck.contains(id)) return
duplicateCheck.add(id)
// タップ・削除した通知のIDと同じか古いなら対象外
if (!id.isNewerThan(nr.nid_read)) {
log.d("update_sub: ${account.acct} skip old notification $id")
return
}
log.d("update_sub: found data that id=$id, > read id ${nr.nid_read}")
val notification = parser.notification(src) ?: return
// アプリミュートと単語ミュート
if (notification.status?.checkMuted() == true) return
// ふぁぼ魔ミュート
when (notification.type) {
TootNotification.TYPE_REBLOG,
TootNotification.TYPE_FAVOURITE,
TootNotification.TYPE_FOLLOW,
TootNotification.TYPE_FOLLOW_REQUEST,
TootNotification.TYPE_FOLLOW_REQUEST_MISSKEY,
TootNotification.TYPE_ADMIN_SIGNUP,
-> {
val who = notification.account
if (who != null && favMuteSet.contains(account.getFullAcct(who))) {
log.d("${account.getFullAcct(who)} is in favMuteSet.")
return
}
}
}
// Mastodon 3.4.0rc1 push policy
if (!policyFilter(notification)) return
// 後から処理したものが先頭に来る
dstListData.add(0, NotificationData(account, notification))
}
fun updateNotification() {
val notificationTag = when (trackingName) {
"" -> "${account.db_id}/_"
else -> "${account.db_id}/$trackingName"
}
val nt = NotificationTracking.load(account.acct.pretty, account.db_id, trackingName)
when (val first = dstListData.firstOrNull()) {
null -> {
log.d("showNotification[${account.acct.pretty}/$notificationTag] cancel notification.")
notificationManager.removeMessageNotification(account, notificationTag)
}
else -> {
when {
// 先頭にあるデータが同じなら、通知を更新しない
// このマーカーは端末再起動時にリセットされるので、再起動後は通知が出るはず
first.notification.time_created_at == nt.post_time && first.notification.id == nt.post_id ->
log.d("showNotification[${account.acct.pretty}] id=${first.notification.id} is already shown.")
Build.VERSION.SDK_INT >= 23 && PrefB.bpDivideNotification() -> {
updateNotificationDivided(notificationTag, nt)
nt.updatePost(
first.notification.id,
first.notification.time_created_at
)
}
else -> {
updateNotificationMerged(notificationTag, first)
nt.updatePost(
first.notification.id,
first.notification.time_created_at
)
}
}
}
}
}
@TargetApi(23)
private fun updateNotificationDivided(
notificationTag: String,
nt: NotificationTracking,
) {
log.d("updateNotificationDivided[${account.acct.pretty}] creating notification(1)")
val activeNotificationMap = notificationManager.getMessageNotifications(notificationTag)
val lastPostTime = nt.post_time
val lastPostId = nt.post_id
for (item in dstListData.reversed()) {
val itemTag = "$notificationTag/${item.notification.id}"
if (lastPostId != null &&
item.notification.time_created_at <= lastPostTime &&
item.notification.id <= lastPostId
) {
// 掲載済みデータより古い通知は再表示しない
log.d("ignore $itemTag ${item.notification.time_created_at} <= $lastPostTime && ${item.notification.id} <= $lastPostId")
continue
}
// ignore if already showing
if (activeNotificationMap.remove(itemTag) != null) {
log.d("ignore $itemTag is in activeNotificationMap")
continue
}
notificationManager.showMessageNotification(
context,
account,
trackingName,
trackingType,
itemTag,
notificationId = item.notification.id.toString()
) { builder ->
builder.setWhen(item.notification.time_created_at)
val summary = item.getNotificationLine()
builder.setContentTitle(summary)
when (val content = item.notification.status?.decoded_content?.notEmpty()) {
null -> builder.setContentText(item.accessInfo.acct.pretty)
else -> {
val style = NotificationCompat.BigTextStyle()
.setBigContentTitle(summary)
.setSummaryText(item.accessInfo.acct.pretty)
.bigText(content)
builder.setStyle(style)
}
}
if (Build.VERSION.SDK_INT < 26) setNotificationSound25(account, builder, item)
}
}
// リストにない通知は消さない。ある通知をユーザが指で削除した際に他の通知が残ってほしい場合がある
}
private fun updateNotificationMerged(
notificationTag: String,
first: NotificationData,
) {
log.d("updateNotificationMerged[${account.acct.pretty}] creating notification(1)")
notificationManager.showMessageNotification(
context,
account,
trackingName,
trackingType,
notificationTag
) { builder ->
builder.setWhen(first.notification.time_created_at)
val a = first.getNotificationLine()
val dataList = dstListData
if (dataList.size == 1) {
builder.setContentTitle(a)
builder.setContentText(account.acct.pretty)
} else {
val header = context.getString(R.string.notification_count, dataList.size)
builder.setContentTitle(header).setContentText(a)
val style = NotificationCompat.InboxStyle()
.setBigContentTitle(header)
.setSummaryText(account.acct.pretty)
for (i in 0 until min(4, dataList.size)) {
style.addLine(dataList[i].getNotificationLine())
}
builder.setStyle(style)
}
if (Build.VERSION.SDK_INT < 26) setNotificationSound25(account, builder, first)
}
}
}
}

View File

@ -1,116 +0,0 @@
@file:Suppress("DEPRECATION")
package jp.juggler.subwaytooter.notification
import android.app.IntentService
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.R
import jp.juggler.util.LogCategory
import kotlinx.coroutines.runBlocking
/*
FCMからメッセージを受信した際に起動されるIntentService
受信したメッセージの通知処理が完了するまでForegroundであり続ける
*/
class PollingForegrounder : IntentService("PollingForegrounder") {
companion object {
internal val log = LogCategory("PollingForegrounder")
internal const val NOTIFICATION_ID_FOREGROUNDER = 2
}
private var lastStatus: String? = null
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onCreate() {
log.d("onCreate")
super.onCreate()
// メインスレッド上でPollingWorkerを初期化しておく
PollingWorker.getInstance(applicationContext)
startForeground(NOTIFICATION_ID_FOREGROUNDER, createNotification(applicationContext, ""))
}
override fun onDestroy() {
log.d("onDestroy")
stopForeground(true)
super.onDestroy()
}
private fun createNotification(context: Context, text: String): Notification {
// 通知タップ時のPendingIntent
val clickIntent = Intent(context, ActMain::class.java)
clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val clickPi = PendingIntent.getActivity(
context,
2,
clickIntent,
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
)
val builder = if (Build.VERSION.SDK_INT >= 26) {
// Android 8 から、通知のスタイルはユーザが管理することになった
// NotificationChannel を端末に登録しておけば、チャネルごとに管理画面が作られる
// The user-visible description of the channel.
val channel = NotificationHelper.createNotificationChannel(
context,
"PollingForegrounder",
"real-time message notifier",
null,
NotificationManagerCompat.IMPORTANCE_LOW
)
NotificationCompat.Builder(context, channel.id)
} else {
NotificationCompat.Builder(context, "not_used")
}
builder
.setContentIntent(clickPi)
.setAutoCancel(false)
.setOngoing(true)
.setSmallIcon(R.drawable.ic_notification) // ここは常に白テーマのアイコンを使う
.setColor(ContextCompat.getColor(context, R.color.Light_colorAccent)) // ここは常に白テーマの色を使う
.setWhen(System.currentTimeMillis())
.setContentTitle(context.getString(R.string.loading_notification_title))
.setContentText(text)
// Android 7.0 ではグループを指定しないと勝手に通知が束ねられてしまう。
// 束ねられた通知をタップしても pi_click が実行されないので困るため、
// アカウント別にグループキーを設定する
builder.setGroup(context.packageName + ":PollingForegrounder")
return builder.build()
}
override fun onHandleIntent(intent: Intent?) {
if (intent == null) return
runBlocking {
val tag = intent.getStringExtra(PollingWorker.EXTRA_TAG)
val context = applicationContext
PollingWorker.handleFCMMessage(context, tag) { sv ->
if (sv.isEmpty() || sv == lastStatus) return@handleFCMMessage
// 状況が変化したらログと通知領域に出力する
lastStatus = sv
log.d("onStatus $sv")
startForeground(NOTIFICATION_ID_FOREGROUNDER, createNotification(context, sv))
}
}
}
}

View File

@ -1,23 +0,0 @@
package jp.juggler.subwaytooter.notification
import android.app.job.JobParameters
import android.app.job.JobService
// JobSchedulerから起動されるサービス。
class PollingService : JobService() {
val pollingWorker by lazy { PollingWorker.getInstance(applicationContext) }
override fun onDestroy() {
super.onDestroy()
pollingWorker.onJobServiceDestroy()
}
override fun onStartJob(params: JobParameters): Boolean {
return pollingWorker.onStartJob(this, params)
}
override fun onStopJob(params: JobParameters): Boolean {
return pollingWorker.onStopJob(params)
}
}

View File

@ -0,0 +1,248 @@
package jp.juggler.subwaytooter.notification
import android.app.NotificationManager
import android.content.Context
import android.net.Uri
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import androidx.work.await
import com.google.firebase.messaging.FirebaseMessaging
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.entity.TootNotification
import jp.juggler.subwaytooter.notification.MessageNotification.removeMessageNotification
import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.table.NotificationCache
import jp.juggler.subwaytooter.table.NotificationTracking
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.PrivacyPolicyChecker
import jp.juggler.util.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.tasks.await
import okhttp3.Request
import ru.gildor.coroutines.okhttp.await
import java.util.*
private val log = LogCategory("PollingUtils")
const val APP_SERVER = "https://mastodon-msg.juggler.jp"
class InstallIdException(ex: Throwable?, message: String) :
RuntimeException(message, ex)
suspend fun loadFirebaseMessagingToken(context: Context): String =
PollingChecker.commonMutex.withLock {
val prefDevice = PrefDevice.from(context)
// 設定ファイルに保持されていたらそれを使う
prefDevice.getString(PrefDevice.KEY_DEVICE_TOKEN, null)
?.notEmpty()?.let { return it }
// 古い形式
// return FirebaseInstanceId.getInstance().getToken(FCM_SENDER_ID, FCM_SCOPE)
// com.google.firebase:firebase-messaging.20.3.0 以降
// implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$kotlinx_coroutines_version"
val sv = FirebaseMessaging.getInstance().token.await()
if (sv.isNullOrBlank()) {
error("getFirebaseMessagingToken: device token is null or empty.")
}
return sv.also {
prefDevice.edit()
.putString(PrefDevice.KEY_DEVICE_TOKEN, it)
.apply()
}
}
// インストールIDを生成する前に、各データの通知登録キャッシュをクリアする
// トークンがまだ生成されていない場合、このメソッドは null を返します。
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun loadInstallId(
context: Context,
deviceToken: String,
progress: suspend (String) -> Unit,
): String = PollingChecker.commonMutex.withLock {
// インストールIDを生成する
// インストールID生成時にSavedAccountテーブルを操作することがあるので
// アカウントリストの取得より先に行う
if (!PrivacyPolicyChecker(context).agreed) {
cancelAllWorkAndJoin(context)
throw InstallIdException(null,
"the user not agreed to privacy policy.")
}
val prefDevice = PrefDevice.from(context)
prefDevice.getString(PrefDevice.KEY_INSTALL_ID, null)
?.notEmpty()?.let { return it }
progress("preparing install id…")
SavedAccount.clearRegistrationCache()
val request = Request.Builder()
.url("$APP_SERVER/counter")
.build()
val response = App1.ok_http_client.newCall(request).await()
val body = response.body?.string()
if (!response.isSuccessful || body?.isEmpty() != false) {
TootApiClient.formatResponse(
response,
"loadInstallId: get/counter failed."
).let { throw InstallIdException(null, it) }
}
(deviceToken + UUID.randomUUID() + body).digestSHA256Base64Url()
.also { prefDevice.edit().putString(PrefDevice.KEY_INSTALL_ID, it).apply() }
}
fun resetNotificationTracking(account: SavedAccount) {
launchDefault {
PollingChecker.accountMutex(account.db_id).withLock {
NotificationTracking.resetTrackingState(account.db_id)
}
}
}
/**
* アプリ設定インポート時など全てのWorkをキャンセル済みであることを確認する
*/
suspend fun cancelAllWorkAndJoin(context: Context) {
val workManager = WorkManager.getInstance(context)
repeat(3) {
while (true) {
workManager.pruneWork()
val workQuery = WorkQuery.Builder.fromStates(
listOf(
WorkInfo.State.ENQUEUED,
WorkInfo.State.RUNNING,
WorkInfo.State.SUCCEEDED,
WorkInfo.State.FAILED,
WorkInfo.State.BLOCKED,
WorkInfo.State.CANCELLED,
)
).build()
val list = workManager.getWorkInfos(workQuery).await()
if (list.isEmpty()) break
list.forEach {
workManager.cancelWorkById(it.id)
}
delay(333L)
}
delay(1000L)
}
}
fun restartAllWorker(context: Context) {
NotificationTracking.resetPostAll()
App1.prepare(context, "restartAllWorker")
EndlessScope.launch {
for (it in SavedAccount.loadAccountList(context)) {
try {
if (it.isPseudo || !it.isConfirmed) continue
PollingWorker.enqueuePolling(context, it.db_id)
} catch (ex: Throwable) {
log.trace(ex, "restartAllWorker failed.")
}
}
}
}
suspend fun onNotificationCleared(context: Context, accountDbId: Long) {
PollingChecker.accountMutex(accountDbId).withLock {
log.d("deleteCacheData: db_id=$accountDbId")
SavedAccount.loadAccount(context, accountDbId) ?: return
NotificationCache.deleteCache(accountDbId)
}
}
suspend fun onNotificationDeleted(dbId: Long, typeName: String) {
PollingChecker.accountMutex(dbId).withLock {
NotificationTracking.updateRead(dbId, typeName)
}
}
fun injectData(
context: Context,
account: SavedAccount,
src: List<TootNotification>,
) = checkNotificationImmediate(context, account.db_id, src)
/**
* すぐにアカウントの通知をチェックする
*/
fun checkNotificationImmediate(
context: Context,
accountDbId: Long,
injectData: List<TootNotification> = emptyList(),
) {
EndlessScope.launch {
try {
PollingChecker(
context = context,
accountDbId = accountDbId,
injectData = injectData,
) { log.i(it) }.check()
} catch (ex: Throwable) {
log.trace(ex, "checkNotificationImmediate failed.")
}
}
}
/**
* メイン画面のonCreate時に全ての通知をチェックする
*/
fun checkNotificationImmediateAll(context: Context) {
EndlessScope.launch {
try {
App1.prepare(context, "checkNotificationImmediateAll")
for (sa in SavedAccount.loadAccountList(context)) {
if (sa.isPseudo || !sa.isConfirmed) continue
PollingChecker(
context = context,
accountDbId = sa.db_id,
) { log.i(it) }.check()
}
} catch (ex: Throwable) {
log.trace(ex, "checkNotificationImmediateAll failed.")
}
}
}
fun recycleClickedNotification(context: Context, uri: Uri) {
val dbId = uri.getQueryParameter("db_id")?.toLongOrNull()
val type = TrackingType.parseStr(uri.getQueryParameter("type"))
val typeName = type.typeName
val id = uri.getQueryParameter("notificationId")?.notEmpty()
log.d("recycleClickedNotification: db_id=$dbId,type=$type,id=$id")
if (dbId == null) return
val notificationManager = systemService<NotificationManager>(context)
if (notificationManager == null) {
log.e("missing NotificationManager system service")
return
}
// 通知をキャンセル
notificationManager.removeMessageNotification(
id = id,
tag = when (typeName) {
"" -> "$dbId/_"
else -> "$dbId/$typeName"
},
)
// DB更新処理
launchDefault {
try {
PollingChecker.accountMutex(dbId).withLock {
NotificationTracking.updateRead(dbId, typeName)
}
} catch (ex: Throwable) {
log.trace(ex)
}
}
}

View File

@ -1,702 +1,104 @@
package jp.juggler.subwaytooter.notification
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.app.job.JobInfo
import android.app.job.JobParameters
import android.app.job.JobScheduler
import android.app.job.JobService
import android.content.ComponentName
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.Uri
import android.net.wifi.WifiManager
import android.os.Build
import android.os.PowerManager
import android.os.SystemClock
import com.google.firebase.messaging.FirebaseMessaging
import jp.juggler.subwaytooter.*
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.global.appPref
import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.pref.PrefS
import jp.juggler.subwaytooter.pref.pref
import jp.juggler.subwaytooter.table.*
import jp.juggler.subwaytooter.util.*
import jp.juggler.util.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.tasks.await
import okhttp3.Request
import ru.gildor.coroutines.okhttp.await
import java.lang.ref.WeakReference
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.coroutineContext
import kotlin.math.max
import androidx.work.*
import jp.juggler.subwaytooter.R
import jp.juggler.util.LogCategory
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.coroutineScope
import java.util.concurrent.TimeUnit
class PollingWorker private constructor(contextArg: Context) {
/*
- WorkManagerのWorker
- アカウント別にuniqueWorkNameを持つ
- アプリが背面にいる間は進捗表示を通知で行う
*/
class PollingWorker(
context: Context,
workerParameters: WorkerParameters,
) : CoroutineWorker(context, workerParameters) {
companion object {
private val log = LogCategory("PollingWorker")
private const val KEY_ACCOUNT_DB_ID = "accountDbId"
val log = LogCategory("PollingWorker")
private const val NOTIFICATION_ID_POLLING_WORKER = 2
const val NOTIFICATION_ID = 1
const val NOTIFICATION_ID_ERROR = 3
private fun workName(accountDbId: Long) =
"PollingForegrounder-$accountDbId"
val mBusyAppDataImportBefore = AtomicBoolean(false)
val mBusyAppDataImportAfter = AtomicBoolean(false)
const val EXTRA_DB_ID = "db_id"
const val EXTRA_TAG = "tag"
const val EXTRA_TASK_ID = "task_id"
const val EXTRA_NOTIFICATION_TYPE = "notification_type"
const val EXTRA_NOTIFICATION_ID = "notificationId"
const val APP_SERVER = "https://mastodon-msg.juggler.jp"
val inject_queue = ConcurrentLinkedQueue<InjectData>()
@SuppressLint("StaticFieldLeak")
private var sInstance: PollingWorker? = null
fun getInstance(applicationContext: Context): PollingWorker {
synchronized(this) {
return sInstance ?: PollingWorker(applicationContext).also { sInstance = it }
}
suspend fun cancelPolling(context: Context, accountDbId: Long) {
val isOk = WorkManager.getInstance(context)
.cancelUniqueWork(workName(accountDbId)).await()
log.i("cancelPolling $accountDbId isOk=$isOk")
}
suspend fun getFirebaseMessagingToken(context: Context): String? {
val prefDevice = PrefDevice.from(context)
suspend fun enqueuePolling(
context: Context,
accountDbId: Long,
existingPeriodicWorkPolicy: ExistingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE,
) {
val uniqueWorkName = workName(accountDbId)
// 設定ファイルに保持されていたらそれを使う
prefDevice
.getString(PrefDevice.KEY_DEVICE_TOKEN, null)
?.notEmpty()?.let { return it }
val data = Data.Builder().apply {
putLong(KEY_ACCOUNT_DB_ID, accountDbId)
}.build()
// 古い形式
// return FirebaseInstanceId.getInstance().getToken(FCM_SENDER_ID, FCM_SCOPE)
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
// com.google.firebase:firebase-messaging.20.3.0 以降
// implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$kotlinx_coroutines_version"
try {
val sv = FirebaseMessaging.getInstance().token.await()
return if (sv.isNullOrBlank()) {
log.e("getFirebaseMessagingToken: missing device token.")
null
} else {
prefDevice
.edit()
.putString(PrefDevice.KEY_DEVICE_TOKEN, sv)
.apply()
sv
}
} catch (ex: Throwable) {
log.trace(ex, "getFirebaseMessagingToken: could not get device token.")
return null
}
val workRequest = PeriodicWorkRequestBuilder<PollingWorker>(
PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS,
TimeUnit.MILLISECONDS,
// flexTimeInterval
// 決まった周期の間の末尾からflexTimeIntervalを引いた時刻の間の何処かで処理が実行される
// (周期より短い範囲で)大きい値の方が「より早いタイミングで」実行されてテストに良い
// (また、setInitialDelayはその何処かの範囲にないと効果がない)
PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS - 1000L,
TimeUnit.MILLISECONDS,
)
.setInitialDelay(1000L, TimeUnit.MILLISECONDS)
.setConstraints(constraints)
.setInputData(data)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
uniqueWorkName,
existingPeriodicWorkPolicy,
workRequest
).await()
}
}
private suspend fun showMessage(text: String) =
CheckerNotification.showMessage(applicationContext, text) {
setForeground(ForegroundInfo(NOTIFICATION_ID_POLLING_WORKER, it))
}
// インストールIDを生成する前に、各データの通知登録キャッシュをクリアする
// トークンがまだ生成されていない場合、このメソッドは null を返します。
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun prepareInstallId(context: Context, job: JobItem? = null): String? {
if (!PrivacyPolicyChecker(context).agreed) {
log.w("prepareInstallId: PrivacyPolicy not agreed.")
return null
}
override suspend fun doWork(): Result = coroutineScope {
try {
val accountDbId = inputData.getLong(KEY_ACCOUNT_DB_ID, -1L)
log.i("doWork start. accountDbId=$accountDbId")
val prefDevice = PrefDevice.from(context)
val context = applicationContext
showMessage(context.getString(R.string.push_notification_checking))
prefDevice.getString(PrefDevice.KEY_INSTALL_ID, null)
?.notEmpty()?.let { return it }
PollingChecker(
context = context,
accountDbId = accountDbId,
) { showMessage(it) }.check()
SavedAccount.clearRegistrationCache()
return try {
val device_token = getFirebaseMessagingToken(context)
?: error("getFirebaseMessagingToken returns null")
val request = Request.Builder()
.url("$APP_SERVER/counter")
.build()
val call = App1.ok_http_client.newCall(request)
job?.currentCall = WeakReference(call)
call.await().use { response ->
val body = response.body?.string()
if (!response.isSuccessful || body?.isEmpty() != false) {
log.e(
TootApiClient.formatResponse(
response,
"getInstallId: get/counter failed."
)
)
return null
}
(device_token + UUID.randomUUID() + body).digestSHA256Base64Url()
.also { prefDevice.edit().putString(PrefDevice.KEY_INSTALL_ID, it).apply() }
}
} catch (ex: Throwable) {
log.trace(ex, "prepareInstallId failed.")
null
}
}
//////////////////////////////////////////////////////////////////////
// タスクの管理
val task_list = TaskList()
private fun scheduleJob(context: Context, jobId: JobId) {
val scheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE)
as? JobScheduler
?: throw NotImplementedError("missing JobScheduler system service")
val component = ComponentName(context, PollingService::class.java)
val builder = JobInfo.Builder(jobId.int, component)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
if (jobId == JobId.Polling) {
val minute = 60000L
val intervalMillis = max(
minute * 5L,
minute * PrefS.spPullNotificationCheckInterval.toInt(context.pref())
)
val flexMillis = max(
minute,
intervalMillis shr 1
)
fun JobInfo.Builder.setPeriodicCompat(intervalMillis: Long, flexMillis: Long) =
this.apply {
if (Build.VERSION.SDK_INT >= 24) {
builder.setPeriodic(intervalMillis, flexMillis)
} else {
builder.setPeriodic(intervalMillis)
}
}
builder
.setPeriodicCompat(intervalMillis, flexMillis)
.setPersisted(true)
Result.success()
} catch (ex: Throwable) {
if (ex is CancellationException) {
log.e("doWork cancelled.")
} else {
builder
.setMinimumLatency(0)
.setOverrideDeadline(60000L)
log.trace(ex, "doWork failed.")
}
val jobInfo = builder.build()
val rv = scheduler.schedule(jobInfo)
if (rv != JobScheduler.RESULT_SUCCESS) {
log.w("scheduler.schedule failed. rv=$rv")
}
}
// タスクの追加
private fun addTask(
context: Context,
removeOld: Boolean,
taskId: TaskId,
taskDataInitializer: JsonObject.() -> Unit = {},
) {
try {
task_list.addLast(
context,
removeOld,
jsonObject {
taskDataInitializer()
put(EXTRA_TASK_ID, taskId.int)
}
)
scheduleJob(context, JobId.Task)
} catch (ex: Throwable) {
log.trace(ex)
}
}
fun queueUpdateNotification(context: Context) {
addTask(context, true, TaskId.AccountUpdated)
}
fun resetNotificationTracking(context: Context, account: SavedAccount) {
addTask(context, false, TaskId.ResetTrackingState) {
put(EXTRA_DB_ID, account.db_id)
}
}
fun injectData(
context: Context,
account: SavedAccount,
src: List<TootNotification>,
) {
if (src.isEmpty()) return
inject_queue.add(InjectData(account.db_id, src))
addTask(context, true, TaskId.DataInjected)
}
fun queueNotificationCleared(context: Context, dbId: Long) {
addTask(context, true, TaskId.Clear) {
put(EXTRA_DB_ID, dbId)
}
}
private fun JsonObject.decodeNotificationUri(uri: Uri) {
putNotNull(
EXTRA_DB_ID,
uri.getQueryParameter("db_id")?.toLongOrNull()
)
putNotNull(
EXTRA_NOTIFICATION_TYPE,
uri.getQueryParameter("type")?.notEmpty()
)
putNotNull(
EXTRA_NOTIFICATION_ID,
uri.getQueryParameter("notificationId")?.notEmpty()
)
}
fun queueNotificationDeleted(context: Context, uri: Uri?) {
if (uri != null) {
addTask(context, false, TaskId.NotificationDelete) {
decodeNotificationUri(uri)
}
}
}
fun queueNotificationClicked(context: Context, uri: Uri?) {
if (uri != null) {
addTask(context, true, TaskId.NotificationClick) {
decodeNotificationUri(uri)
}
}
}
fun queueAppDataImportBefore(context: Context) {
mBusyAppDataImportBefore.set(true)
mBusyAppDataImportAfter.set(true)
addTask(context, false, TaskId.AppDataImportBefore)
}
fun queueAppDataImportAfter(context: Context) {
addTask(context, false, TaskId.AppDataImportAfter)
}
fun queueFCMTokenUpdated(context: Context) {
addTask(context, true, TaskId.FcmDeviceToken)
}
fun queueBootCompleted(context: Context) {
addTask(context, true, TaskId.BootCompleted)
}
fun queuePackageReplaced(context: Context) {
addTask(context, true, TaskId.PackageReplaced)
}
private val job_status = AtomicReference<String>(null)
var workerStatus: String
get() = job_status.get()
set(x) {
log.d("workerStatus:$x")
job_status.set(x)
}
// IntentServiceが作ったスレッドから呼ばれる
suspend fun handleFCMMessage(
context: Context,
tag: String?,
progress: (String) -> Unit,
) {
log.d("handleFCMMessage: start. tag=$tag")
val time_start = SystemClock.elapsedRealtime()
// この呼出でIntentServiceがstartForegroundする
progress("=>")
// タスクを追加
task_list.addLast(
context,
true,
JsonObject().apply {
put(EXTRA_TASK_ID, TaskId.FcmMessage.int)
if (tag != null) put(EXTRA_TAG, tag)
}
)
progress("==>")
// 疑似ジョブを開始
val pw = getInstance(context)
pw.addJobFCM()
// 疑似ジョブが終了するまで待機する
while (true) {
// ジョブが完了した?
val now = SystemClock.elapsedRealtime()
if (!pw.hasJob(JobId.Push)) {
log.d(
"handleFCMMessage: JOB_FCM completed. time=${
(now - time_start).div(1000f).toString("%.2f")
}"
)
break
}
// ジョブの状況を通知する
progress(job_status.get() ?: "(null)")
// 少し待機
delay(50L)
}
}
fun onAppSettingStop(context: Context) {
try {
scheduleJob(context, JobId.Polling)
} catch (ex: Throwable) {
log.trace(ex, "PollingWorker.scheduleJob failed.")
}
}
}
val context: Context
val appState: AppState
val pref: SharedPreferences
private val connectivityManager: ConnectivityManager
val notificationManager: NotificationManager
private val scheduler: JobScheduler
private val powerManager: PowerManager?
private val powerLock: PowerManager.WakeLock
private val wifiManager: WifiManager?
private val wifiLock: WifiManager.WifiLock
private val startedJobList = LinkedList<JobItem>()
private val workerNotifier = Channel<Unit>(capacity = Channel.CONFLATED)
fun notifyWorker() =
workerNotifier.trySend(Unit)
init {
log.d("init")
val context = contextArg.applicationContext
this.context = context
// クラッシュレポートによると App1.onCreate より前にここを通る場合がある
// データベースへアクセスできるようにする
this.appState = App1.prepare(context, "PollingWorker.init")
this.pref = appPref
this.connectivityManager = systemService(context)
?: error("missing ConnectivityManager system service")
this.notificationManager = systemService(context)
?: error("missing NotificationManager system service")
this.scheduler = systemService(context)
?: error("missing JobScheduler system service")
this.powerManager = systemService(context)
?: error("missing PowerManager system service")
// WifiManagerの取得時はgetApplicationContext を使わないとlintに怒られる
this.wifiManager = systemService(context.applicationContext)
?: error("missing WifiManager system service")
powerLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
PollingWorker::class.java.name
)
powerLock.setReferenceCounted(false)
wifiLock = if (Build.VERSION.SDK_INT >= 29) {
wifiManager.createWifiLock(
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
PollingWorker::class.java.name
)
} else {
@Suppress("DEPRECATION")
wifiManager.createWifiLock(PollingWorker::class.java.name)
}
wifiLock.setReferenceCounted(false)
launchDefault { worker() }
}
@SuppressLint("WakelockTimeout")
private fun acquirePowerLock() {
log.d("acquire power lock...")
try {
if (!powerLock.isHeld) {
powerLock.acquire()
}
} catch (ex: Throwable) {
log.trace(ex)
}
try {
if (!wifiLock.isHeld) {
wifiLock.acquire()
}
} catch (ex: Throwable) {
log.trace(ex)
}
}
private fun releasePowerLock() {
log.d("release power lock...")
try {
if (powerLock.isHeld) {
powerLock.release()
}
} catch (ex: Throwable) {
log.trace(ex)
}
try {
if (wifiLock.isHeld) {
wifiLock.release()
}
} catch (ex: Throwable) {
log.trace(ex)
}
}
private suspend fun worker() {
workerStatus = "worker start."
try {
suspend fun isActive() = coroutineContext[Job]?.isActive == true
while (isActive()) {
while (true) {
handleJobItem(synchronized(startedJobList) {
for (ji in startedJobList) {
if (ji.abJobCancelled.get()) continue
if (ji.abWorkerAttached.compareAndSet(false, true)) {
return@synchronized ji
}
}
null
} ?: break)
}
try {
workerNotifier.receive()
} catch (ignored: ClosedReceiveChannelException) {
}
}
} finally {
workerStatus = "worker end."
}
}
private suspend fun handleJobItem(item: JobItem) {
try {
workerStatus = "start job ${item.jobId}"
acquirePowerLock()
try {
item.run(this@PollingWorker)
} finally {
releasePowerLock()
}
} catch (ex: Throwable) {
log.trace(ex)
} finally {
workerStatus = "end job ${item.jobId}"
}
}
//////////////////////////////////////////////////////////////////////
// ジョブの管理
// JobService#onDestroy から呼ばれる
fun onJobServiceDestroy() {
log.d("onJobServiceDestroy")
synchronized(startedJobList) {
val it = startedJobList.iterator()
while (it.hasNext()) {
val item = it.next()
if (item.jobId == JobId.Push) continue
it.remove()
item.cancel(false)
}
}
}
// JobService#onStartJob から呼ばれる
fun onStartJob(jobService: JobService, params: JobParameters): Boolean {
return when (val jobId = JobId.from(params.jobId)) {
null -> {
log.e("onStartJob: unknown jobId $params.jobId")
false
}
else -> {
val item = JobItem(jobId, params, WeakReference(jobService))
addStartedJob(item, true)
// return True if your context needs to process the work (on a separate thread).
// return False if there's no more work to be done for this job.
true
}
}
}
// FCMメッセージイベントから呼ばれる
private fun hasJob(@Suppress("SameParameterValue") jobId: JobId): Boolean {
synchronized(startedJobList) {
return startedJobList.any { it.jobId == jobId }
}
}
// FCMメッセージイベントから呼ばれる
private fun addJobFCM() {
addStartedJob(JobItem(JobId.Push), false)
}
// onStartJobから呼ばれる
private fun addStartedJob(item: JobItem, bRemoveOld: Boolean) {
val jobId = item.jobId
// 同じジョブ番号がジョブリストにあるか?
synchronized(startedJobList) {
if (bRemoveOld) {
val it = startedJobList.iterator()
while (it.hasNext()) {
val itemOld = it.next()
if (itemOld.jobId == jobId) {
log.w("addJob: jobId=$jobId, old job cancelled.")
// 同じジョブをすぐに始めるのだからrescheduleはfalse
itemOld.cancel(false)
it.remove()
}
}
}
log.d("addJob: jobId=$jobId, add to list.")
startedJobList.add(item)
}
workerNotifier.trySend(Unit)
}
// JobService#onStopJob から呼ばれる
// return True to indicate to the JobManager whether you'd like to reschedule this job based on the retry criteria provided at job creation-time.
// return False to drop the job. Regardless of the value returned, your job must stop executing.
fun onStopJob(params: JobParameters): Boolean {
val jobId = JobId.from(params.jobId)
// 同じジョブ番号がジョブリストにあるか?
synchronized(startedJobList) {
startedJobList.removeFirst { it.jobId == jobId }?.let { item ->
log.w("onStopJob: jobId=$jobId, set cancel flag.")
// リソースがなくてStopされるのだからrescheduleはtrue
item.cancel(true)
return true // reschedule
}
}
// 該当するジョブを依頼されていない
log.w("onStopJob: jobId=$jobId, not started..")
return false
}
fun processInjectedData(injectedAccounts: HashSet<Long>) {
while (true) {
val data = inject_queue.poll() ?: break
val account = SavedAccount.loadAccount(context, data.accountDbId) ?: continue
val list = data.list
log.d("${account.acct} processInjectedData +${list.size}")
if (list.isNotEmpty()) injectedAccounts.add(account.db_id)
NotificationCache(data.accountDbId).apply {
load()
inject(account, list)
}
}
}
// ポーリングが完了した
fun onPollingComplete(requiredNextPolling: Boolean) {
when (requiredNextPolling) {
// まだスケジュールされてないなら登録する
true -> if (!scheduler.allPendingJobs.any { it.id == JobId.Polling.int }) {
log.d("registering next polling…")
scheduleJob(context, JobId.Polling)
}
// Pull通知を必要とするアカウントが存在しないなら、スケジュール登録を解除する
else -> try {
log.d("polling job is no longer required.")
scheduler.cancel(JobId.Polling.int)
} catch (ex: Throwable) {
log.trace(ex)
}
}
}
// ジョブ完了後にメインスレッドで呼ばれる
fun onJobComplete(item: JobItem) {
synchronized(startedJobList) {
startedJobList.remove(item)
}
// ジョブ終了報告
item.refJobService?.get()?.let { jobService ->
try {
val willReschedule = item.abReschedule.get()
log.d("sending jobFinished. willReschedule=$willReschedule")
jobService.jobFinished(item.jobParams, willReschedule)
} catch (ex: Throwable) {
log.trace(ex, "jobFinished failed.")
}
}
}
// return false if app data import started.
fun onStartTask(taskId: TaskId): Boolean {
when (taskId) {
TaskId.AppDataImportBefore -> {
// フォアグラウンドサービスの通知は消されないらしい
notificationManager.cancelAll()
scheduler.cancelAll()
mBusyAppDataImportBefore.set(false)
return false
}
TaskId.AppDataImportAfter -> {
mBusyAppDataImportAfter.set(false)
mBusyAppDataImportBefore.set(false)
NotificationTracking.resetPostAll()
// fall
}
else -> Unit
}
// アプリデータのインポート処理がビジーな間、他のジョブは実行されない
return when {
mBusyAppDataImportBefore.get() || mBusyAppDataImportAfter.get() -> false
else -> true
Result.success()
}
}
}

View File

@ -8,6 +8,7 @@ import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.SubscriptionServerKey
import jp.juggler.util.*
import kotlinx.coroutines.CancellationException
import okhttp3.Request
import okhttp3.Response
@ -100,7 +101,7 @@ class PushSubscriptionHelper(
put("server_key", serverKey)
}
.toPostRequestBuilder()
.url("${PollingWorker.APP_SERVER}/webpushserverkey")
.url("$APP_SERVER/webpushserverkey")
.build()
).also { result ->
@ -140,7 +141,7 @@ class PushSubscriptionHelper(
put("endpoint", endpoint)
}
.toPostRequestBuilder()
.url("${PollingWorker.APP_SERVER}/webpushendpoint")
.url("$APP_SERVER/webpushendpoint")
.build()
).also { result ->
result.response?.let { res ->
@ -202,16 +203,30 @@ class PushSubscriptionHelper(
// 現在の購読状態を取得できないので、毎回購読の更新を行う
// FCMのデバイスIDを取得
val deviceId = PollingWorker.getFirebaseMessagingToken(context)
?: return TootApiResult(error = context.getString(R.string.missing_fcm_device_id))
val deviceId = try {
loadFirebaseMessagingToken(context)
} catch (ex: Throwable) {
log.trace(ex)
return when (ex) {
is CancellationException -> null
else -> TootApiResult(error = context.getString(R.string.missing_fcm_device_id))
}
}
// アクセストークン
val accessToken = account.misskeyApiToken
?: return TootApiResult(error = "missing misskeyApiToken.")
// インストールIDを取得
val installId = PollingWorker.prepareInstallId(context)
?: return TootApiResult(error = context.getString(R.string.missing_install_id))
val installId = try {
loadInstallId(context, deviceId) { log.i(it) }
} catch (ex: Throwable) {
log.trace(ex)
return when (ex) {
is CancellationException -> null
else -> TootApiResult(error = context.getString(R.string.missing_install_id))
}
}
// クライアント識別子
val clientIdentifier = "$accessToken$installId".digestSHA256Base64Url()
@ -235,7 +250,7 @@ class PushSubscriptionHelper(
// 2018/9/1 の上記コミット以降、Misskeyでもサーバ公開鍵を得られるようになった
val endpoint =
"${PollingWorker.APP_SERVER}/webpushcallback/${deviceId.encodePercent()}/${account.acct.ascii.encodePercent()}/$flags/$clientIdentifier/misskey"
"$APP_SERVER/webpushcallback/${deviceId.encodePercent()}/${account.acct.ascii.encodePercent()}/$flags/$clientIdentifier/misskey"
// アプリサーバが過去のendpoint urlに410を返せるよう、状態を通知する
val r = registerEndpoint(client, deviceId, endpoint.toUri().encodedPath!!)
@ -295,13 +310,26 @@ class PushSubscriptionHelper(
}
// FCMのデバイスIDを取得
val deviceId = PollingWorker.getFirebaseMessagingToken(context)
?: return TootApiResult(error = context.getString(R.string.missing_fcm_device_id))
val deviceId = try {
loadFirebaseMessagingToken(context)
} catch (ex: Throwable) {
log.trace(ex)
return when (ex) {
is CancellationException -> null
else -> TootApiResult(error = context.getString(R.string.missing_fcm_device_id))
}
}
// インストールIDを取得
val installId = PollingWorker.prepareInstallId(context)
?: return TootApiResult(error = context.getString(R.string.missing_install_id))
val installId = try {
loadInstallId(context, deviceId) { log.i(it) }
} catch (ex: Throwable) {
log.trace(ex)
return when (ex) {
is CancellationException -> null
else -> TootApiResult(error = context.getString(R.string.missing_install_id))
}
}
// アクセストークン
val accessToken = account.getAccessToken()
?: return TootApiResult(error = "missing access token.")
@ -313,7 +341,7 @@ class PushSubscriptionHelper(
val clientIdentifier = "$accessToken$installId".digestSHA256Base64Url()
val endpoint =
"${PollingWorker.APP_SERVER}/webpushcallback/${deviceId.encodePercent()}/${account.acct.ascii.encodePercent()}/$flags/$clientIdentifier"
"$APP_SERVER/webpushcallback/${deviceId.encodePercent()}/${account.acct.ascii.encodePercent()}/$flags/$clientIdentifier"
val newAlerts = JsonObject().apply {
put("follow", account.notification_follow)
@ -377,7 +405,7 @@ class PushSubscriptionHelper(
put("install_id", installId)
}
.toPostRequestBuilder()
.url("${PollingWorker.APP_SERVER}/webpushtokencheck")
.url("$APP_SERVER/webpushtokencheck")
.build()
)

View File

@ -0,0 +1,81 @@
package jp.juggler.subwaytooter.notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import jp.juggler.subwaytooter.ActCallback
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.LogCategory
object ServerTimeoutNotification {
private val log = LogCategory("ServerTimeoutNotification")
private const val NOTIFICATION_ID_ERROR = 3
fun NotificationManager.createServerTimeoutNotification(
context: Context,
account: SavedAccount,
) {
val instance = account.apiHost.pretty
val accountDbId = account.db_id
// 通知タップ時のPendingIntent
val clickIntent = Intent(context, ActCallback::class.java)
// FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY を付与してはいけない
clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val clickPi = PendingIntent.getActivity(
context,
3,
clickIntent,
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
)
val builder = if (Build.VERSION.SDK_INT >= 26) {
// Android 8 から、通知のスタイルはユーザが管理することになった
// NotificationChannel を端末に登録しておけば、チャネルごとに管理画面が作られる
val channel = NotificationHelper.createNotificationChannel(
context,
"ErrorNotification",
"Error",
null,
2 /* NotificationManager.IMPORTANCE_LOW */
)
NotificationCompat.Builder(context, channel.id)
} else {
NotificationCompat.Builder(context, "not_used")
}
builder
.setContentIntent(clickPi)
.setAutoCancel(true)
.setSmallIcon(R.drawable.ic_notification) // ここは常に白テーマのアイコンを使う
.setColor(
ContextCompat.getColor(
context,
R.color.Light_colorAccent
)
) // ここは常に白テーマの色を使う
.setWhen(System.currentTimeMillis())
.setGroup(context.packageName + ":" + "Error")
val header = context.getString(R.string.error_notification_title)
val summary = context.getString(R.string.error_notification_summary)
builder
.setContentTitle(header)
.setContentText("$summary: $instance")
val style = NotificationCompat.InboxStyle()
.setBigContentTitle(header)
.setSummaryText(summary)
style.addLine(instance)
builder.setStyle(style)
val tag = accountDbId.toString()
notify(tag, NOTIFICATION_ID_ERROR, builder.build())
}
}

View File

@ -1,24 +0,0 @@
package jp.juggler.subwaytooter.notification
// タスクID
enum class TaskId(val int: Int) {
Polling(1),
DataInjected(2),
Clear(3),
AppDataImportBefore(4),
AppDataImportAfter(5),
FcmDeviceToken(6),
FcmMessage(7),
BootCompleted(8),
PackageReplaced(9),
NotificationDelete(10),
NotificationClick(11),
AccountUpdated(12),
ResetTrackingState(13),
;
companion object {
fun from(int: Int) = values().firstOrNull { it.int == int }
}
}

View File

@ -1,889 +0,0 @@
package jp.juggler.subwaytooter.notification
import android.annotation.TargetApi
import android.app.PendingIntent
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import jp.juggler.subwaytooter.ActCallback
import jp.juggler.subwaytooter.EventReceiver
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootApiCallback
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.table.*
import jp.juggler.util.*
import kotlinx.coroutines.*
import okhttp3.Call
import java.lang.ref.WeakReference
import java.util.*
import kotlin.math.min
class TaskRunner(
private val pollingWorker: PollingWorker,
val job: JobItem,
private val taskId: TaskId,
private val taskData: JsonObject,
) {
companion object {
private val log = LogCategory("TaskRunner")
private var workerStatus = ""
set(value) {
field = value
PollingWorker.workerStatus = value
}
}
val context = pollingWorker.context
val notificationManager = pollingWorker.notificationManager
val pref = pollingWorker.pref
val threadList = LinkedList<AccountRunner>()
val errorInstance = ArrayList<String>()
private fun createErrorNotification(instanceList: ArrayList<String>) {
if (instanceList.isEmpty()) return
// 通知タップ時のPendingIntent
val clickIntent = Intent(context, ActCallback::class.java)
// FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY を付与してはいけない
clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val clickPi = PendingIntent.getActivity(
context,
3,
clickIntent,
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
)
val builder = if (Build.VERSION.SDK_INT >= 26) {
// Android 8 から、通知のスタイルはユーザが管理することになった
// NotificationChannel を端末に登録しておけば、チャネルごとに管理画面が作られる
val channel = NotificationHelper.createNotificationChannel(
context,
"ErrorNotification",
"Error",
null,
2 /* NotificationManager.IMPORTANCE_LOW */
)
NotificationCompat.Builder(context, channel.id)
} else {
NotificationCompat.Builder(context, "not_used")
}
builder
.setContentIntent(clickPi)
.setAutoCancel(true)
.setSmallIcon(R.drawable.ic_notification) // ここは常に白テーマのアイコンを使う
.setColor(
ContextCompat.getColor(
context,
R.color.Light_colorAccent
)
) // ここは常に白テーマの色を使う
.setWhen(System.currentTimeMillis())
.setGroup(context.packageName + ":" + "Error")
val header = context.getString(R.string.error_notification_title)
val summary = context.getString(R.string.error_notification_summary)
builder
.setContentTitle(header)
.setContentText(summary + ": " + instanceList[0])
val style = NotificationCompat.InboxStyle()
.setBigContentTitle(header)
.setSummaryText(summary)
for (i in 0..4) {
if (i >= instanceList.size) break
style.addLine(instanceList[i])
}
builder.setStyle(style)
notificationManager.notify(PollingWorker.NOTIFICATION_ID_ERROR, builder.build())
}
private fun NotificationData.getNotificationLine(): String {
val name = when (PrefB.bpShowAcctInSystemNotification(pref)) {
false -> notification.accountRef?.decoded_display_name
true -> {
val acctPretty = notification.accountRef?.get()?.acct?.pretty
if (acctPretty?.isNotEmpty() == true) {
"@$acctPretty"
} else {
null
}
}
} ?: "?"
return "- " + when (notification.type) {
TootNotification.TYPE_MENTION,
TootNotification.TYPE_REPLY,
->
context.getString(R.string.display_name_replied_by, name)
TootNotification.TYPE_RENOTE,
TootNotification.TYPE_REBLOG,
->
context.getString(R.string.display_name_boosted_by, name)
TootNotification.TYPE_QUOTE ->
context.getString(R.string.display_name_quoted_by, name)
TootNotification.TYPE_STATUS ->
context.getString(R.string.display_name_posted_by, name)
TootNotification.TYPE_UPDATE ->
context.getString(R.string.display_name_updates_post, name)
TootNotification.TYPE_STATUS_REFERENCE ->
context.getString(R.string.display_name_references_post, name)
TootNotification.TYPE_FOLLOW ->
context.getString(R.string.display_name_followed_by, name)
TootNotification.TYPE_UNFOLLOW ->
context.getString(R.string.display_name_unfollowed_by, name)
TootNotification.TYPE_ADMIN_SIGNUP ->
context.getString(R.string.display_name_signed_up, name)
TootNotification.TYPE_FAVOURITE ->
context.getString(R.string.display_name_favourited_by, name)
TootNotification.TYPE_EMOJI_REACTION_PLEROMA,
TootNotification.TYPE_EMOJI_REACTION,
TootNotification.TYPE_REACTION,
->
context.getString(R.string.display_name_reaction_by, name)
TootNotification.TYPE_VOTE,
TootNotification.TYPE_POLL_VOTE_MISSKEY,
->
context.getString(R.string.display_name_voted_by, name)
TootNotification.TYPE_FOLLOW_REQUEST,
TootNotification.TYPE_FOLLOW_REQUEST_MISSKEY,
->
context.getString(R.string.display_name_follow_request_by, name)
TootNotification.TYPE_FOLLOW_REQUEST_ACCEPTED_MISSKEY ->
context.getString(R.string.display_name_follow_request_accepted_by, name)
TootNotification.TYPE_POLL ->
context.getString(R.string.end_of_polling_from, name)
else -> "?"
}
}
private fun deleteCacheData(dbId: Long?) {
if (dbId != null) {
log.d("Notification clear! db_id=$dbId")
SavedAccount.loadAccount(context, dbId) ?: return
NotificationCache.deleteCache(dbId)
}
}
private fun beforePolling(): Boolean {
// タスクによってはポーリング前にすることがある
when (taskId) {
TaskId.BootCompleted ->
NotificationTracking.resetPostAll()
TaskId.PackageReplaced ->
NotificationTracking.resetPostAll()
TaskId.DataInjected ->
pollingWorker.processInjectedData(job.injectedAccounts)
TaskId.ResetTrackingState ->
NotificationTracking.resetTrackingState(taskData.long(PollingWorker.EXTRA_DB_ID))
// プッシュ通知が届いた
TaskId.FcmMessage -> {
var bDone = false
val tag = taskData.string(PollingWorker.EXTRA_TAG)
if (tag != null) {
if (tag.startsWith("acct<>")) {
val acct = tag.substring(6)
val sa = SavedAccount.loadAccountByAcct(context, acct)
if (sa != null) {
NotificationCache.resetLastLoad(sa.db_id)
job.injectedAccounts.add(sa.db_id)
bDone = true
}
}
if (!bDone) {
for (sa in SavedAccount.loadByTag(context, tag)) {
NotificationCache.resetLastLoad(sa.db_id)
job.injectedAccounts.add(sa.db_id)
bDone = true
}
}
}
if (!bDone) {
// タグにマッチする情報がなかった場合、全部読み直す
NotificationCache.resetLastLoad()
}
}
TaskId.Clear -> {
deleteCacheData(taskData.long(PollingWorker.EXTRA_DB_ID))
}
TaskId.NotificationDelete -> {
val dbId = taskData.long(PollingWorker.EXTRA_DB_ID)
val type =
TrackingType.parseStr(taskData.string(PollingWorker.EXTRA_NOTIFICATION_TYPE))
val typeName = type.typeName
val id = taskData.string(PollingWorker.EXTRA_NOTIFICATION_ID)
log.d("Notification deleted! db_id=$dbId,type=$type,id=$id")
if (dbId != null) {
NotificationTracking.updateRead(dbId, typeName)
}
return false
}
TaskId.NotificationClick -> {
val dbId = taskData.long(PollingWorker.EXTRA_DB_ID)
val type =
TrackingType.parseStr(taskData.string(PollingWorker.EXTRA_NOTIFICATION_TYPE))
val typeName = type.typeName
val id = taskData.string(PollingWorker.EXTRA_NOTIFICATION_ID).notEmpty()
log.d("Notification clicked! db_id=$dbId,type=$type,id=$id")
if (dbId != null) {
// 通知をキャンセル
val notificationTag = when (typeName) {
"" -> "$dbId/_"
else -> "$dbId/$typeName"
}
if (id != null) {
val itemTag = "$notificationTag/$id"
notificationManager.cancel(itemTag, PollingWorker.NOTIFICATION_ID)
} else {
notificationManager.cancel(
notificationTag,
PollingWorker.NOTIFICATION_ID
)
}
// DB更新処理
NotificationTracking.updateRead(dbId, typeName)
}
return false
}
else -> Unit
}
return true
}
private suspend fun prepareInstallId() {
// インストールIDを生成する
// インストールID生成時にSavedAccountテーブルを操作することがあるので
// アカウントリストの取得より先に行う
if (job.installId == null) {
PollingWorker.workerStatus = "make install id"
job.installId = PollingWorker.prepareInstallId(context, job)
}
}
private suspend fun startForAccount(sa: SavedAccount) {
if (sa.isPseudo) return
threadList.add(AccountRunner(sa).apply { start() })
}
private suspend fun waitAllAccounts() {
while (true) {
// 同じホスト名が重複しないようにSetに集める
val liveSet = TreeSet<Host>()
for (t in threadList) {
if (!t.isActive) continue
if (job.isJobCancelled) t.cancel()
liveSet.add(t.account.apiHost)
}
if (liveSet.isEmpty()) break
PollingWorker.workerStatus =
"waiting ${liveSet.joinToString(", ") { it.pretty }}"
delay(if (job.isJobCancelled) 100L else 1000L)
}
}
suspend fun runTask() {
workerStatus = "start task $taskId"
coroutineScope {
try {
if (!beforePolling()) return@coroutineScope
prepareInstallId()
// アカウント別に処理スレッドを作る
PollingWorker.workerStatus = "create account threads"
if (job.injectedAccounts.isNotEmpty()) {
// 更新対象アカウントが限られているなら、そのdb_idだけ処理する
job.injectedAccounts.forEach { dbId ->
SavedAccount.loadAccount(context, dbId)?.let { startForAccount(it) }
}
} else {
// 全てのアカウントを処理する
SavedAccount.loadAccountList(context).forEach { startForAccount(it) }
}
waitAllAccounts()
synchronized(errorInstance) {
createErrorNotification(errorInstance)
}
if (!job.isJobCancelled) job.bPollingComplete = true
} catch (ex: Throwable) {
log.trace(ex, "task execution failed.")
} finally {
log.d(")runTask: taskId=$taskId")
PollingWorker.workerStatus = "end task $taskId"
}
}
}
inner class AccountRunner(val account: SavedAccount) {
private var suspendJob: Job? = null
private lateinit var parser: TootParser
private lateinit var cache: NotificationCache
private var currentCall: WeakReference<Call>? = null
private var policyFilter: (TootNotification) -> Boolean = when (account.push_policy) {
"followed" -> { it ->
val who = it.account
when {
who == null -> true
account.isMe(who) -> true
else -> UserRelation.load(account.db_id, who.id).following
}
}
"follower" -> { it ->
val who = it.account
when {
it.type == TootNotification.TYPE_FOLLOW ||
it.type == TootNotification.TYPE_FOLLOW_REQUEST -> true
who == null -> true
account.isMe(who) -> true
else -> UserRelation.load(account.db_id, who.id).followed_by
}
}
"none" -> { _ -> false }
else -> { _ -> true }
}
///////////////////
val isActive: Boolean
get() = suspendJob?.isActive ?: true
private val onCallCreated: (Call) -> Unit =
{ currentCall = WeakReference(it) }
private val client = TootApiClient(context, callback = object : TootApiCallback {
override suspend fun isApiCancelled() =
job.isJobCancelled || (suspendJob?.isCancelled == true)
}).apply {
currentCallCallback = onCallCreated
}
private val favMuteSet: HashSet<Acct> get() = job.favMuteSet
private val onError: (TootApiResult) -> Unit = { result ->
val sv = result.error
if (sv?.contains("Timeout") == true && !account.dont_show_timeout) {
synchronized(errorInstance) {
if (!errorInstance.any { it == sv }) errorInstance.add(sv)
}
}
}
fun cancel() {
try {
currentCall?.get()?.cancel()
} catch (ex: Throwable) {
log.trace(ex)
}
}
suspend fun start() {
coroutineScope {
this@AccountRunner.suspendJob = launch(Dispatchers.IO) {
runSuspend()
}
}
}
private suspend fun runSuspend() {
try {
// 疑似アカウントはチェック対象外
if (account.isPseudo) return
// 未確認アカウントはチェック対象外
if (!account.isConfirmed) return
log.d("${account.acct}: runSuspend start.")
client.account = account
val wps = PushSubscriptionHelper(context, account)
if (wps.flags != 0) {
job.bPollingRequired.set(true)
val (instance, instanceResult) = TootInstance.get(client)
if (instance == null) {
if (instanceResult != null) {
log.e("${instanceResult.error} ${instanceResult.requestInfo}".trim())
account.updateNotificationError("${instanceResult.error} ${instanceResult.requestInfo}".trim())
}
return
}
if (job.isJobCancelled) return
}
wps.updateSubscription(client) ?: return // cancelled.
val wpsLog = wps.logString
if (wpsLog.isNotEmpty()) {
log.d("PushSubscriptionHelper: ${account.acct.pretty} $wpsLog")
}
if (job.isJobCancelled) return
if (wps.flags == 0) {
if (account.lastNotificationError != null) {
account.updateNotificationError(null)
}
return
}
this.cache = NotificationCache(account.db_id).apply {
load()
requestAsync(
client,
account,
wps.flags,
onError = onError,
isCancelled = { job.isJobCancelled }
)
}
if (job.isJobCancelled) return
this.parser = TootParser(context, account)
if (PrefB.bpSeparateReplyNotificationGroup(pref)) {
var tr = TrackingRunner(
trackingType = TrackingType.NotReply,
trackingName = NotificationHelper.TRACKING_NAME_DEFAULT
)
tr.checkAccount()
if (job.isJobCancelled) return
tr.updateNotification()
//
tr = TrackingRunner(
trackingType = TrackingType.Reply,
trackingName = NotificationHelper.TRACKING_NAME_REPLY
)
tr.checkAccount()
if (job.isJobCancelled) return
tr.updateNotification()
} else {
val tr = TrackingRunner(
trackingType = TrackingType.All,
trackingName = NotificationHelper.TRACKING_NAME_DEFAULT
)
tr.checkAccount()
if (job.isJobCancelled) return
tr.updateNotification()
}
} catch (ex: Throwable) {
log.trace(ex)
} finally {
pollingWorker.notifyWorker()
}
}
inner class TrackingRunner(
var trackingType: TrackingType = TrackingType.All,
var trackingName: String = "",
) {
private lateinit var nr: NotificationTracking
private val duplicateCheck = HashSet<EntityId>()
private val dstListData = LinkedList<NotificationData>()
fun checkAccount() {
this.nr =
NotificationTracking.load(account.acct.pretty, account.db_id, trackingName)
fun JsonObject.isMention() =
when (NotificationCache.parseNotificationType(account, this)) {
TootNotification.TYPE_REPLY, TootNotification.TYPE_MENTION -> true
else -> false
}
val jsonList = when (trackingType) {
TrackingType.All -> cache.data
TrackingType.Reply -> cache.data.filter { it.isMention() }
TrackingType.NotReply -> cache.data.filter { !it.isMention() }
}
// 新しい順に並んでいる。先頭から10件までを処理する。ただし処理順序は古い方から
val size = min(10, jsonList.size)
for (i in (0 until size).reversed()) {
if (job.isJobCancelled) return
updateSub(jsonList[i])
}
if (job.isJobCancelled) return
// 種別チェックより先に、cache中の最新のIDを「最後に表示した通知」に指定する
// nid_show は通知タップ時に参照されるので、通知を表示する際は必ず更新・保存する必要がある
// 種別チェックより優先する
val latestId = cache.filterLatestId(account) {
when (trackingType) {
TrackingType.Reply -> it.isMention()
TrackingType.NotReply -> !it.isMention()
else -> true
}
}
if (latestId != null) nr.nid_show = latestId
nr.save(account.acct.pretty)
}
private fun updateSub(src: JsonObject) {
val id = NotificationCache.getEntityOrderId(account, src)
if (id.isDefault || duplicateCheck.contains(id)) return
duplicateCheck.add(id)
// タップ・削除した通知のIDと同じか古いなら対象外
if (!id.isNewerThan(nr.nid_read)) {
log.d("update_sub: ${account.acct} skip old notification $id")
return
}
log.d("update_sub: found data that id=$id, > read id ${nr.nid_read}")
val notification = parser.notification(src) ?: return
// アプリミュートと単語ミュート
if (notification.status?.checkMuted() == true) return
// ふぁぼ魔ミュート
when (notification.type) {
TootNotification.TYPE_REBLOG,
TootNotification.TYPE_FAVOURITE,
TootNotification.TYPE_FOLLOW,
TootNotification.TYPE_FOLLOW_REQUEST,
TootNotification.TYPE_FOLLOW_REQUEST_MISSKEY,
TootNotification.TYPE_ADMIN_SIGNUP,
-> {
val who = notification.account
if (who != null && favMuteSet.contains(account.getFullAcct(who))) {
log.d("${account.getFullAcct(who)} is in favMuteSet.")
return
}
}
}
// Mastodon 3.4.0rc1 push policy
if (!policyFilter(notification)) return
// 後から処理したものが先頭に来る
dstListData.add(0, NotificationData(account, notification))
}
fun updateNotification() {
val notificationTag = when (trackingName) {
"" -> "${account.db_id}/_"
else -> "${account.db_id}/$trackingName"
}
val nt = NotificationTracking.load(account.acct.pretty, account.db_id, trackingName)
when (val first = dstListData.firstOrNull()) {
null -> {
log.d("showNotification[${account.acct.pretty}/$notificationTag] cancel notification.")
removeNotification(notificationTag)
}
else -> {
when {
// 先頭にあるデータが同じなら、通知を更新しない
// このマーカーは端末再起動時にリセットされるので、再起動後は通知が出るはず
first.notification.time_created_at == nt.post_time && first.notification.id == nt.post_id ->
log.d("showNotification[${account.acct.pretty}] id=${first.notification.id} is already shown.")
Build.VERSION.SDK_INT >= 23 && PrefB.bpDivideNotification(pref) -> {
updateNotificationDivided(notificationTag, nt)
nt.updatePost(
first.notification.id,
first.notification.time_created_at
)
}
else -> {
updateNotificationMerged(notificationTag, first)
nt.updatePost(
first.notification.id,
first.notification.time_created_at
)
}
}
}
}
}
private fun removeNotification(notificationTag: String) {
if (Build.VERSION.SDK_INT >= 23 && PrefB.bpDivideNotification(pref)) {
notificationManager.activeNotifications?.filterNotNull()?.filter {
it.id == PollingWorker.NOTIFICATION_ID && it.tag.startsWith("$notificationTag/")
}?.forEach {
log.d("cancel: ${it.tag} context=${account.acct.pretty} $notificationTag")
notificationManager.cancel(it.tag, PollingWorker.NOTIFICATION_ID)
}
} else {
notificationManager.cancel(notificationTag, PollingWorker.NOTIFICATION_ID)
}
}
@TargetApi(23)
private fun updateNotificationDivided(
notificationTag: String,
nt: NotificationTracking,
) {
log.d("updateNotificationDivided[${account.acct.pretty}] creating notification(1)")
val activeNotificationMap =
notificationManager.activeNotifications?.filterNotNull()?.filter {
it.id == PollingWorker.NOTIFICATION_ID && it.tag.startsWith("$notificationTag/")
}?.map { Pair(it.tag, it) }?.toMutableMap() ?: mutableMapOf()
val lastPostTime = nt.post_time
val lastPostId = nt.post_id
for (item in dstListData.reversed()) {
val itemTag = "$notificationTag/${item.notification.id}"
if (lastPostId != null &&
item.notification.time_created_at <= lastPostTime &&
item.notification.id <= lastPostId
) {
// 掲載済みデータより古い通知は再表示しない
log.d("ignore $itemTag ${item.notification.time_created_at} <= $lastPostTime && ${item.notification.id} <= $lastPostId")
continue
}
// ignore if already showing
if (activeNotificationMap.remove(itemTag) != null) {
log.d("ignore $itemTag is in activeNotificationMap")
continue
}
createNotification(
itemTag,
notificationId = item.notification.id.toString()
) { builder ->
builder.setWhen(item.notification.time_created_at)
val summary = item.getNotificationLine()
builder.setContentTitle(summary)
when (val content = item.notification.status?.decoded_content?.notEmpty()) {
null -> builder.setContentText(item.accessInfo.acct.pretty)
else -> {
val style = NotificationCompat.BigTextStyle()
.setBigContentTitle(summary)
.setSummaryText(item.accessInfo.acct.pretty)
.bigText(content)
builder.setStyle(style)
}
}
if (Build.VERSION.SDK_INT < 26) setNotificationSound25(builder, item)
}
}
// リストにない通知は消さない。ある通知をユーザが指で削除した際に他の通知が残ってほしい場合がある
}
private fun updateNotificationMerged(
notificationTag: String,
first: NotificationData,
) {
log.d("updateNotificationMerged[${account.acct.pretty}] creating notification(1)")
createNotification(notificationTag) { builder ->
builder.setWhen(first.notification.time_created_at)
val a = first.getNotificationLine()
val dataList = dstListData
if (dataList.size == 1) {
builder.setContentTitle(a)
builder.setContentText(account.acct.pretty)
} else {
val header = context.getString(R.string.notification_count, dataList.size)
builder.setContentTitle(header).setContentText(a)
val style = NotificationCompat.InboxStyle()
.setBigContentTitle(header)
.setSummaryText(account.acct.pretty)
for (i in 0 until min(4, dataList.size)) {
style.addLine(dataList[i].getNotificationLine())
}
builder.setStyle(style)
}
if (Build.VERSION.SDK_INT < 26) setNotificationSound25(builder, first)
}
}
// Android 8 未満ではチャネルではなく通知に個別にスタイルを設定する
@TargetApi(25)
private fun setNotificationSound25(
builder: NotificationCompat.Builder,
item: NotificationData,
) {
var iv = 0
if (PrefB.bpNotificationSound(pref)) {
var soundUri: Uri? = null
try {
val whoAcct = account.getFullAcct(item.notification.account)
soundUri = AcctColor.getNotificationSound(whoAcct).mayUri()
} catch (ex: Throwable) {
log.trace(ex)
}
if (soundUri == null) {
soundUri = account.sound_uri.mayUri()
}
var bSoundSet = false
if (soundUri != null) {
try {
builder.setSound(soundUri)
bSoundSet = true
} catch (ex: Throwable) {
log.trace(ex)
}
}
if (!bSoundSet) {
iv = iv or NotificationCompat.DEFAULT_SOUND
}
}
if (PrefB.bpNotificationVibration(pref)) {
iv = iv or NotificationCompat.DEFAULT_VIBRATE
}
if (PrefB.bpNotificationLED(pref)) {
iv = iv or NotificationCompat.DEFAULT_LIGHTS
}
builder.setDefaults(iv)
}
private fun createNotification(
notificationTag: String,
notificationId: String? = null,
setContent: (builder: NotificationCompat.Builder) -> Unit,
) {
log.d("showNotification[${account.acct.pretty}] creating notification(1)")
val builder = if (Build.VERSION.SDK_INT >= 26) {
// Android 8 から、通知のスタイルはユーザが管理することになった
// NotificationChannel を端末に登録しておけば、チャネルごとに管理画面が作られる
val channel = NotificationHelper.createNotificationChannel(
context,
account,
trackingName
)
NotificationCompat.Builder(context, channel.id)
} else {
NotificationCompat.Builder(context, "not_used")
}
builder.apply {
val params = listOf(
"db_id" to account.db_id.toString(),
"type" to trackingType.str,
"notificationId" to notificationId
).mapNotNull {
when (val second = it.second) {
null -> null
else -> "${it.first.encodePercent()}=${second.encodePercent()}"
}
}.joinToString("&")
val flag = PendingIntent.FLAG_UPDATE_CURRENT or
(if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
PendingIntent.getActivity(
context,
257,
Intent(context, ActCallback::class.java).apply {
data = "subwaytooter://notification_click/?$params".toUri()
// FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY を付与してはいけない
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
flag
)?.let { setContentIntent(it) }
PendingIntent.getBroadcast(
context,
257,
Intent(context, EventReceiver::class.java).apply {
action = EventReceiver.ACTION_NOTIFICATION_DELETE
data = "subwaytooter://notification_delete/?$params".toUri()
},
flag
)?.let { setDeleteIntent(it) }
setAutoCancel(true)
// 常に白テーマのアイコンを使う
setSmallIcon(R.drawable.ic_notification)
// 常に白テーマの色を使う
builder.color = ContextCompat.getColor(context, R.color.Light_colorAccent)
// Android 7.0 ではグループを指定しないと勝手に通知が束ねられてしまう。
// 束ねられた通知をタップしても pi_click が実行されないので困るため、
// アカウント別にグループキーを設定する
setGroup(context.packageName + ":" + account.acct.ascii)
}
log.d("showNotification[${account.acct.pretty}] creating notification(3)")
setContent(builder)
log.d("showNotification[${account.acct.pretty}] set notification...")
notificationManager.notify(
notificationTag,
PollingWorker.NOTIFICATION_ID,
builder.build()
)
}
}
}
}

View File

@ -2,13 +2,14 @@ package jp.juggler.subwaytooter.notification
enum class TrackingType(
val str: String,
val typeName: String
val typeName: String,
) {
All("all", NotificationHelper.TRACKING_NAME_DEFAULT),
Reply("reply", NotificationHelper.TRACKING_NAME_REPLY),
NotReply("notReply", NotificationHelper.TRACKING_NAME_DEFAULT);
All("all", MessageNotification.TRACKING_NAME_DEFAULT),
Reply("reply", MessageNotification.TRACKING_NAME_REPLY),
NotReply("notReply", MessageNotification.TRACKING_NAME_DEFAULT);
companion object {
fun parseStr(str: String?) = values().firstOrNull { it.str == str } ?: All
private val valuesCache = values()
fun parseStr(str: String?) = valuesCache.firstOrNull { it.str == str } ?: All
}
}

View File

@ -11,8 +11,7 @@ import jp.juggler.subwaytooter.api.entity.TootNotification
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.global.appDatabase
import jp.juggler.util.*
import java.util.*
import kotlin.collections.ArrayList
import kotlinx.coroutines.yield
class NotificationCache(private val account_db_id: Long) {
@ -254,7 +253,6 @@ class NotificationCache(private val account_db_id: Long) {
account: SavedAccount,
flags: Int,
onError: (TootApiResult) -> Unit,
isCancelled: () -> Boolean,
) {
val now = System.currentTimeMillis()
@ -276,11 +274,7 @@ class NotificationCache(private val account_db_id: Long) {
try {
for (nTry in 0..3) {
if (isCancelled()) {
log.d("cancelled.")
return
}
yield()
val result = if (account.isMisskey) {
client.request(path, account.putMisskeyApiToken().toPostRequestBuilder())

View File

@ -11,7 +11,7 @@ import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.global.appDatabase
import jp.juggler.subwaytooter.notification.PollingWorker
import jp.juggler.subwaytooter.notification.checkNotificationImmediate
import jp.juggler.subwaytooter.util.LinkHelper
import jp.juggler.util.*
import java.util.*
@ -928,7 +928,7 @@ class SavedAccount(
arrayOf(db_id.toString())
)
}
PollingWorker.queueUpdateNotification(context)
checkNotificationImmediate(context, db_id)
}
}
}

View File

@ -124,8 +124,8 @@ class CustomEmojiLister(
launchMain {
try {
getList(accessInfo, withAliases).let { callback?.invoke(it) }
}catch(ex:Throwable){
log.trace(ex,"getList failed.")
} catch (ex: Throwable) {
log.trace(ex, "getList failed.")
}
}
return null

View File

@ -1,80 +0,0 @@
package jp.juggler.subwaytooter.util
import android.content.Context
import jp.juggler.util.*
import java.io.FileNotFoundException
import java.util.*
class TaskList {
companion object {
private val log = LogCategory("TaskList")
private const val FILE_TASK_LIST = "JOB_TASK_LIST"
}
private lateinit var _list: LinkedList<JsonObject>
@Synchronized
private fun prepareList(context: Context): LinkedList<JsonObject> {
if (!::_list.isInitialized) {
_list = LinkedList()
try {
context.openFileInput(FILE_TASK_LIST).use { inputStream ->
inputStream.readBytes().decodeUTF8().decodeJsonArray().objectList().forEach {
_list.add(it)
}
}
} catch (ex: FileNotFoundException) {
log.e(ex, "prepareList: file not found.")
} catch (ex: Throwable) {
log.trace(ex, "TaskList: prepareArray failed.")
}
}
return _list
}
@Synchronized
private fun saveArray(context: Context) {
val list = prepareList(context)
try {
log.d("saveArray size=${list.size}")
val data = JsonArray(list).toString().encodeUTF8()
context.openFileOutput(FILE_TASK_LIST, Context.MODE_PRIVATE).use {
it.write(data)
}
} catch (ex: Throwable) {
log.trace(ex, "TaskList: saveArray failed.size=${list.size}")
}
}
@Synchronized
fun addLast(context: Context, removeOld: Boolean, taskData: JsonObject) {
val list = prepareList(context)
if (removeOld) {
val it = list.iterator()
while (it.hasNext()) {
val item = it.next()
if (taskData == item) it.remove()
}
}
list.addLast(taskData)
saveArray(context)
}
@Suppress("unused")
@Synchronized
fun hasNext(context: Context): Boolean {
return prepareList(context).isNotEmpty()
}
@Synchronized
fun next(context: Context): JsonObject? {
val list = prepareList(context)
val item = if (list.isEmpty()) null else list.removeFirst()
saveArray(context)
return item
}
}

View File

@ -15,7 +15,7 @@ val <T : Any> T.wrapWeakReference: WeakReference<T>
// kotlinx.coroutines 1.5.0 で GlobalScopeがdeprecated になったが、
// プロセスが生きてる間ずっと動いててほしいものや特にキャンセルのタイミングがないコルーチンでは使い続けたい
private object EndlessScope : CoroutineScope {
object EndlessScope : CoroutineScope {
override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext
}

View File

@ -1149,4 +1149,5 @@
<string name="post_error_attachments_duplicated">Misskeyは添付データの重複を許可していません。</string>
<string name="use_web_settings">Web設定を使う</string>
<string name="content">本文</string>
<string name="push_notification_checking">プッシュ通知の処理中…</string>
</resources>

View File

@ -1158,4 +1158,5 @@
<string name="post_error_attachments_duplicated">Misskey does not allow duplicate in attachments.</string>
<string name="use_web_settings">(Use web setting)</string>
<string name="content">Content</string>
<string name="push_notification_checking">Processing push notifications…</string>
</resources>

View File

@ -29,7 +29,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:7.0.4'
classpath 'com.android.tools.build:gradle:7.2.1'
classpath 'com.google.gms:google-services:4.3.10'
//noinspection DifferentKotlinGradleVersion

View File

@ -1,5 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
#Mon Jun 13 20:53:58 JST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME