突然のWorkManager移行
This commit is contained in:
parent
feee046b66
commit
4a1b4f91c2
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -171,7 +171,6 @@ class AppState(
|
|||
|
||||
// initからプロパティにアクセスする場合、そのプロパティはinitより上で定義されていないとダメっぽい
|
||||
// そしてその他のメソッドからval プロパティにアクセスする場合、そのプロパティはメソッドより上で初期化されていないとダメっぽい
|
||||
|
||||
init {
|
||||
|
||||
this.density = context.resources.displayMetrics.density
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -169,7 +169,6 @@ class TootApiClient(
|
|||
httpClient.onCallCreated = value
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
internal suspend fun isApiCancelled() = callback.isApiCancelled()
|
||||
|
||||
suspend fun publishApiProgress(s: String) {
|
||||
|
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通知を削除した後に呼ばれる
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
package jp.juggler.subwaytooter.notification
|
||||
|
||||
class JobCancelledException : RuntimeException("job is cancelled.")
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue