利用可能なプッシュ配送サービスがない場合の警告とガイドを追加。端末上でPlay開発者サービスを利用できるかどうか判定。 onNewToken契機で プッシュ配送エンドポイントの更新処理を開始。

This commit is contained in:
tateisu 2023-02-10 01:23:29 +09:00
parent fd3e329e7e
commit 4f194f649e
12 changed files with 192 additions and 134 deletions

View File

@ -16,6 +16,7 @@ sub cmd($){
}
cmd "./gradlew --stop";
cmd "rm -rf .gradle/caches/build-cache-*";
cmd "./gradlew clean";
cmd "./gradlew assembleNoFcmRelease";
cmd "./gradlew assembleFcmRelease";

View File

@ -1,7 +1,12 @@
package jp.juggler.subwaytooter.push
import android.content.Context
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.firebase.messaging.FirebaseMessaging
import jp.juggler.util.log.LogCategory
import kotlinx.coroutines.tasks.await
/*
// ビルド要求
// com.google.firebase:firebase-messaging.20.3.0 以降
@ -19,13 +24,50 @@ import kotlinx.coroutines.tasks.await
が出る場合はビルド設定を確認すること
*/
@Suppress("unused")
class FcmTokenLoader {
object FcmTokenLoader {
private val log = LogCategory("FcmTokenLoader")
private fun connectionResultString(i: Int) = when (i) {
ConnectionResult.UNKNOWN -> "UNKNOWN"
ConnectionResult.SUCCESS -> "SUCCESS"
ConnectionResult.SERVICE_MISSING -> "SERVICE_MISSING"
ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED -> "SERVICE_VERSION_UPDATE_REQUIRED"
ConnectionResult.SERVICE_DISABLED -> "SERVICE_DISABLED"
ConnectionResult.SIGN_IN_REQUIRED -> "SIGN_IN_REQUIRED"
ConnectionResult.INVALID_ACCOUNT -> "INVALID_ACCOUNT"
ConnectionResult.RESOLUTION_REQUIRED -> "RESOLUTION_REQUIRED"
ConnectionResult.NETWORK_ERROR -> "NETWORK_ERROR"
ConnectionResult.INTERNAL_ERROR -> "INTERNAL_ERROR"
ConnectionResult.SERVICE_INVALID -> "SERVICE_INVALID"
ConnectionResult.DEVELOPER_ERROR -> "DEVELOPER_ERROR"
ConnectionResult.LICENSE_CHECK_FAILED -> "LICENSE_CHECK_FAILED"
ConnectionResult.CANCELED -> "CANCELED"
ConnectionResult.TIMEOUT -> "TIMEOUT"
ConnectionResult.INTERRUPTED -> "INTERRUPTED"
ConnectionResult.API_UNAVAILABLE -> "API_UNAVAILABLE"
ConnectionResult.SIGN_IN_FAILED -> "SIGN_IN_FAILED"
ConnectionResult.SERVICE_UPDATING -> "SERVICE_UPDATING"
ConnectionResult.SERVICE_MISSING_PERMISSION -> "SERVICE_MISSING_PERMISSION"
ConnectionResult.RESTRICTED_PROFILE -> "RESTRICTED_PROFILE"
ConnectionResult.RESOLUTION_ACTIVITY_NOT_FOUND -> "RESOLUTION_ACTIVITY_NOT_FOUND"
ConnectionResult.API_DISABLED -> "API_DISABLED"
ConnectionResult.API_DISABLED_FOR_CONNECTION -> "API_DISABLED_FOR_CONNECTION"
else -> "Unknown($i)"
}
fun isPlayServiceAvailavle(context: Context): Boolean {
val errorCode = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
if (errorCode == ConnectionResult.SUCCESS) return true
log.w("isPlayServiceAvailavle=${connectionResultString(errorCode)}")
return false
}
//
suspend fun getToken(): String? =
FirebaseMessaging.getInstance().token.await()
suspend fun deleteToken(){
suspend fun deleteToken() {
FirebaseMessaging.getInstance().deleteToken().await()
}
}

View File

@ -2,16 +2,18 @@ package jp.juggler.subwaytooter.push
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.pref.prefDevice
import jp.juggler.util.log.LogCategory
import jp.juggler.util.os.applicationContextSafe
import jp.juggler.util.os.checkAppForeground
import kotlinx.coroutines.runBlocking
/**
* FCMのイベントを受け取るサービス
* - IntentServiceの一種なのでワーカースレッドから呼ばれるrunBlockingして良し
*/
class MyFcmService : FirebaseMessagingService() {
companion object{
companion object {
private val log = LogCategory("MyFcmService")
}
@ -21,7 +23,12 @@ class MyFcmService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
try {
checkAppForeground("MyFcmService.onNewToken")
fcmHandler.onTokenChanged(token)
val context = applicationContextSafe
when (context.prefDevice.pushDistributor) {
null, "", PrefDevice.PUSH_DISTRIBUTOR_FCM -> {
PushWorker.enqueueRegisterEndpoint(context)
}
}
} catch (ex: Throwable) {
log.e(ex, "onNewToken failed.")
} finally {
@ -37,9 +44,7 @@ class MyFcmService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
try {
checkAppForeground("MyFcmService.onMessageReceived")
runBlocking {
fcmHandler.onMessageReceived( remoteMessage.data)
}
applicationContextSafe.pushRepo.handleFcmMessage(remoteMessage.data)
} catch (ex: Throwable) {
log.e(ex, "onMessageReceived failed.")
} finally {

View File

@ -373,9 +373,6 @@
<meta-data
android:name="jp.juggler.subwaytooter.pref.LazyContextInitializer"
android:value="androidx.startup" />
<meta-data
android:name="jp.juggler.subwaytooter.push.FcmHandlerInitializer"
android:value="androidx.startup" />
<meta-data
android:name="jp.juggler.subwaytooter.pref.PrefDeviceInitializer"
android:value="androidx.startup" />

View File

@ -23,6 +23,7 @@ import jp.juggler.subwaytooter.api.runApiTask2
import jp.juggler.subwaytooter.api.showApiError
import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.column.startLoading
import jp.juggler.subwaytooter.dialog.DlgConfirm.okDialog
import jp.juggler.subwaytooter.dialog.actionsDialog
import jp.juggler.subwaytooter.dialog.pickAccount
import jp.juggler.subwaytooter.dialog.runInProgress
@ -356,7 +357,7 @@ fun ActMain.handleSharedIntent(intent: Intent) {
// アカウントを追加/更新したらappServerHashの取得をやりなおす
suspend fun ActMain.updatePushDistributer() {
when {
fcmHandler.noFcm && prefDevice.pushDistributor.isNullOrEmpty() -> {
fcmHandler.noFcm(this) && prefDevice.pushDistributor.isNullOrEmpty() -> {
selectPushDistributor()
// 選択しなかった場合は購読の更新を行わない
}
@ -384,52 +385,58 @@ fun AppCompatActivity.selectPushDistributor() {
else -> this
}
actionsDialog(getString(R.string.select_push_delivery_service)) {
if (fcmHandler.hasFcm) {
val upDistrobutors = UnifiedPush.getDistributors(
context,
features = ArrayList(listOf(UnifiedPush.FEATURE_BYTES_MESSAGE))
)
val hasFcm = fcmHandler.hasFcm(context)
if (upDistrobutors.isEmpty() && !hasFcm) {
okDialog(R.string.push_distributor_not_available)
} else {
actionsDialog(getString(R.string.select_push_delivery_service)) {
if (hasFcm) {
action(
getString(R.string.firebase_cloud_messaging)
.appendChecked(lastDistributor == PrefDevice.PUSH_DISTRIBUTOR_FCM)
) {
runInProgress(cancellable = false) { reporter ->
withContext(AppDispatchers.DEFAULT) {
pushRepo.switchDistributor(
PrefDevice.PUSH_DISTRIBUTOR_FCM,
reporter = reporter
)
}
}
}
}
for (packageName in upDistrobutors) {
action(
packageName.appendChecked(lastDistributor == packageName)
) {
runInProgress(cancellable = false) { reporter ->
withContext(AppDispatchers.DEFAULT) {
pushRepo.switchDistributor(
packageName,
reporter = reporter
)
}
}
}
}
action(
getString(R.string.firebase_cloud_messaging)
.appendChecked(lastDistributor == PrefDevice.PUSH_DISTRIBUTOR_FCM)
getString(R.string.none)
.appendChecked(lastDistributor == PrefDevice.PUSH_DISTRIBUTOR_NONE)
) {
runInProgress(cancellable = false) { reporter ->
withContext(AppDispatchers.DEFAULT) {
pushRepo.switchDistributor(
PrefDevice.PUSH_DISTRIBUTOR_FCM,
PrefDevice.PUSH_DISTRIBUTOR_NONE,
reporter = reporter
)
}
}
}
}
for (packageName in UnifiedPush.getDistributors(
context,
features = ArrayList(listOf(UnifiedPush.FEATURE_BYTES_MESSAGE))
)) {
action(
packageName.appendChecked(lastDistributor == packageName)
) {
runInProgress(cancellable = false) { reporter ->
withContext(AppDispatchers.DEFAULT) {
pushRepo.switchDistributor(
packageName,
reporter = reporter
)
}
}
}
}
action(
getString(R.string.none)
.appendChecked(lastDistributor == PrefDevice.PUSH_DISTRIBUTOR_NONE)
) {
runInProgress(cancellable = false) { reporter ->
withContext(AppDispatchers.DEFAULT) {
pushRepo.switchDistributor(
PrefDevice.PUSH_DISTRIBUTOR_NONE,
reporter = reporter
)
}
}
}
}
}
}

View File

@ -588,11 +588,13 @@ class SideMenuAdapter(
}
private fun notificationActionRecommend(): Pair<Int, () -> Unit>? = when {
// 通知権限がない場合の警告とアクション
actMain.prNotification.spec.listNotGranded(actMain).isNotEmpty() ->
Pair(R.string.notification_permission_not_granted) {
actMain.prNotification.openAppSetting(actMain)
}
(actMain.prefDevice.pushDistributor.isNullOrEmpty() && actMain.fcmHandler.noFcm) ||
// プッシュ配送が選択されていない場合の警告とアクション
(actMain.prefDevice.pushDistributor.isNullOrEmpty() && fcmHandler.noFcm(actMain)) ||
actMain.prefDevice.pushDistributor == PUSH_DISTRIBUTOR_NONE ->
Pair(R.string.notification_push_distributor_disabled) {
actMain.selectPushDistributor()

View File

@ -1,6 +1,8 @@
package jp.juggler.subwaytooter.dialog
import android.annotation.SuppressLint
import android.text.method.LinkMovementMethod
import android.text.util.Linkify
import android.view.View
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
@ -121,4 +123,39 @@ object DlgConfirm {
}
}
}
suspend fun AppCompatActivity.okDialog(@StringRes messageId: Int, vararg args: Any?) =
okDialog(getString(messageId, *args))
suspend fun AppCompatActivity.okDialog(message: CharSequence, title: CharSequence? = null) {
suspendCancellableCoroutine { cont ->
try {
val views = DlgConfirmBinding.inflate(layoutInflater)
views.cbSkipNext.visibility = View.GONE
views.tvMessage.apply {
movementMethod = LinkMovementMethod.getInstance()
autoLinkMask = Linkify.WEB_URLS
text = message
}
val dialog = AlertDialog.Builder(this).apply {
setView(views.root)
setCancelable(true)
title?.let { setTitle(it) }
setPositiveButton(R.string.ok) { _, _ ->
if (cont.isActive) cont.resume(Unit)
}
}.create()
dialog.setOnDismissListener {
if (cont.isActive) cont.resumeWithException(CancellationException("dialog closed."))
}
dialog.show()
cont.invokeOnCancellation { dialog.dismissSafe() }
} catch (ex: Throwable) {
cont.resumeWithException(ex)
}
}
}
}

View File

@ -1,69 +1,36 @@
package jp.juggler.subwaytooter.push
import android.content.Context
import androidx.startup.AppInitializer
import androidx.startup.Initializer
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.coroutine.EmptyScope
import jp.juggler.util.log.LogCategory
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import jp.juggler.subwaytooter.BuildConfig
import jp.juggler.subwaytooter.pref.prefDevice
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory
import kotlinx.coroutines.withContext
private val log = LogCategory("FcmHandler")
val fcmHandler = FcmHandler
@Suppress("MemberVisibilityCanBePrivate", "RedundantSuspendModifier")
class FcmHandler(
private val context: Context,
) {
companion object {
val reNoFcm = """noFcm""".toRegex(RegexOption.IGNORE_CASE)
}
object FcmHandler {
private val log = LogCategory("FcmHandler")
val fcmToken = MutableStateFlow(context.prefDevice.fcmToken)
fun hasFcm(context: Context): Boolean =
FcmTokenLoader.isPlayServiceAvailavle(context)
val noFcm :Boolean
get()= reNoFcm.containsMatchIn(BuildConfig.FLAVOR)
fun noFcm(context: Context): Boolean =
!hasFcm(context)
val hasFcm get() = !noFcm
fun onTokenChanged(token: String?) {
context.prefDevice.fcmToken = token
EmptyScope.launch(AppDispatchers.IO) { fcmToken.emit(token) }
}
suspend fun onMessageReceived(data: Map<String, String>) {
try {
context.pushRepo.handleFcmMessage(data)
} catch (ex: Throwable) {
log.e(ex, "onMessage failed.")
}
}
suspend fun deleteFcmToken() =
suspend fun deleteFcmToken(context: Context) =
withContext(AppDispatchers.IO) {
// 古いトークンを覚えておく
context.prefDevice.fcmToken
?.takeIf { it.isNotEmpty() }
?.let { context.prefDevice.fcmTokenExpired = it }
// FCMから削除する
log.i("deleteFcmToken: start")
FcmTokenLoader().deleteToken()
log.i("deleteFcmToken: end")
onTokenChanged(null)
log.i("deleteFcmToken complete")
loadFcmToken()?.notEmpty()?.let {
context.prefDevice.fcmTokenExpired = it
}
// FCMにトークン変更を依頼する
FcmTokenLoader.deleteToken()
}
suspend fun loadFcmToken(): String? = try {
withContext(AppDispatchers.IO) {
log.i("loadFcmToken start")
val token = FcmTokenLoader().getToken()
log.i("loadFcmToken onTokenChanged")
onTokenChanged(token)
log.i("loadFcmToken end")
token
FcmTokenLoader.getToken()
}
} catch (ex: Throwable) {
// https://github.com/firebase/firebase-android-sdk/issues/4053
@ -77,25 +44,3 @@ class FcmHandler(
null
}
}
/**
* AndroidManifest.xml androidx.startup.InitializationProvider から参照される
*/
@Suppress("unused")
class FcmHandlerInitializer : Initializer<FcmHandler> {
override fun dependencies(): List<Class<out Initializer<*>>> =
emptyList()
override fun create(context: Context): FcmHandler {
val newHandler = FcmHandler(context.applicationContext)
log.i("FcmHandlerInitializer hasFcm=${newHandler.hasFcm}, BuildConfig.FLAVOR=${BuildConfig.FLAVOR}")
EmptyScope.launch{
newHandler.loadFcmToken()
}
return newHandler
}
}
val Context.fcmHandler: FcmHandler
get() = AppInitializer.getInstance(this)
.initializeComponent(FcmHandlerInitializer::class.java)

View File

@ -141,21 +141,22 @@ class PushRepo(
UnifiedPush.unregisterApp(context)
// FCMトークンの削除。これでこの端末のこのアプリへの古いエンドポイント登録はgoneになり消えるはず
fcmHandler.deleteFcmToken()
fcmHandler.deleteFcmToken(context)
when (pushDistributor) {
null, "" -> {
// 特に変更しない(アクセストークン更新時に呼ばれる
reporter.setMessage("enqueueRegisterEndpoint for ${pushDistributor}")
enqueueRegisterEndpoint(context)
}
PrefDevice.PUSH_DISTRIBUTOR_NONE -> {
// 購読解除
reporter.setMessage("SubscriptionUpdateService.launch")
reporter.setMessage("enqueueRegisterEndpoint for ${pushDistributor}")
enqueueRegisterEndpoint(context)
}
PrefDevice.PUSH_DISTRIBUTOR_FCM -> {
// 特にイベントは来ないので、プッシュ購読をやりなおす
reporter.setMessage("SubscriptionUpdateService.launch")
reporter.setMessage("enqueueRegisterEndpoint for ${pushDistributor}")
enqueueRegisterEndpoint(context)
}
else -> {
@ -196,7 +197,8 @@ class PushRepo(
* ワーカーからnewUpEndpoint()が呼ばれる
*/
suspend fun newUpEndpoint(upEndpoint: String) {
refReporter?.get()?.setMessage("新しい UnifiedPush endpoint URL を取得しました")
refReporter?.get()
?.setMessage(context.getString(R.string.unified_push_got_new_endpoint_url))
val upPackageName = UnifiedPush.getDistributor(context).notEmpty()
?: error("missing upPackageName")
@ -223,6 +225,7 @@ class PushRepo(
suspend fun registerEndpoint(
keepAliveMode: Boolean,
) {
refReporter?.get()?.setMessage("registerEndpoint for ${prefDevice.pushDistributor}")
subscriptionMutex.withLock {
log.i("registerEndpoint: keepAliveMode=$keepAliveMode")
@ -230,7 +233,8 @@ class PushRepo(
try {
// 期限切れのUPエンドポイントがあればそれ経由の中継を解除する
prefDevice.fcmTokenExpired.notEmpty()?.let {
refReporter?.get()?.setMessage("期限切れのFCMデバイストークンをアプリサーバから削除しています")
refReporter?.get()
?.setMessage(context.getString(R.string.removing_old_fcm_token))
log.i("remove fcmTokenExpired")
apiAppServer.endpointRemove(fcmToken = it)
prefDevice.fcmTokenExpired = null
@ -242,7 +246,8 @@ class PushRepo(
try {
// 期限切れのUPエンドポイントがあればそれ経由の中継を解除する
prefDevice.upEndpointExpired.notEmpty()?.let {
refReporter?.get()?.setMessage("期限切れのUnifiedPushエンドポイントをアプリサーバから削除しています")
refReporter?.get()
?.setMessage(context.getString(R.string.removing_old_unified_push_url))
log.i("remove upEndpointExpired")
apiAppServer.endpointRemove(upUrl = it)
prefDevice.upEndpointExpired = null
@ -274,19 +279,23 @@ class PushRepo(
var willRemoveSubscription = false
// アプリサーバにendpointを登録する
refReporter?.get()?.setMessage("アプリサーバにプッシュサービスの情報を送信しています")
val hasFcm = fcmHandler.hasFcm(context)
if (!fcmHandler.hasFcm && prefDevice.pushDistributor == PrefDevice.PUSH_DISTRIBUTOR_FCM) {
if (!hasFcm && prefDevice.pushDistributor == PrefDevice.PUSH_DISTRIBUTOR_FCM) {
log.w("fmc selected, but this is noFcm build. unset distributer.")
prefDevice.pushDistributor = null
}
// アプリサーバにendpointを登録する
refReporter?.get()
?.setMessage(context.getString(R.string.sending_push_distributor_info_to_app_server))
val acctHashList = acctHashMap.keys.toList()
val json = when (prefDevice.pushDistributor) {
null, "" -> when {
fcmHandler.hasFcm -> {
hasFcm -> {
log.i("registerEndpoint dist=FCM(default), acctHashList=${acctHashList.size}")
registerEndpointFcm(acctHashList)
}

View File

@ -1240,4 +1240,9 @@
<string name="notification_push_distributor_disabled">通知のプッシュ配送サービスが選択されていません</string>
<string name="notification_accent_color">通知のアクセント色</string>
<string name="pleroma_features">Pleroma機能</string>
<string name="unified_push_got_new_endpoint_url">UnifiedPushの新しいendpoint URLを取得しました…</string>
<string name="removing_old_fcm_token">期限切れのFCMデバイストークンをアプリサーバから削除しています…</string>
<string name="removing_old_unified_push_url">期限切れのUnifiedPushエンドポイントをアプリサーバから削除しています…</string>
<string name="sending_push_distributor_info_to_app_server">アプリサーバにプッシュサービスの情報を送信しています…</string>
<string name="push_distributor_not_available">プッシュ配送サービスを利用できません。UnifiedPush対応のプッシュ配送アプリをインストールすることで、プッシュ通知を受け取ることができます。詳細 https://unifiedpush.org/</string>
</resources>

View File

@ -1256,4 +1256,9 @@
<string name="notification_push_distributor_disabled">Notification push distributor not selected.</string>
<string name="notification_accent_color">Notification accent color</string>
<string name="pleroma_features">Pleroma features</string>
<string name="unified_push_got_new_endpoint_url">Got new UnifiedPush endpoint URL…</string>
<string name="removing_old_fcm_token">Removing expired FCM device token from app server…</string>
<string name="removing_old_unified_push_url">Removing expired UnifiedPush endpoint URL from app server…</string>
<string name="sending_push_distributor_info_to_app_server">Sending push service information to app server…</string>
<string name="push_distributor_not_available">Push distributor is not available. You can receive push notifications by installing a UnifiedPush compatible push delivery app. see https://unifiedpush.org/</string>
</resources>

View File

@ -1,7 +1,10 @@
package jp.juggler.subwaytooter.push
@Suppress("RedundantSuspendModifier")
class FcmTokenLoader {
import android.content.Context
@Suppress("RedundantSuspendModifier", "UNUSED_PARAMETER")
object FcmTokenLoader {
suspend fun getToken(): String? = null
suspend fun deleteToken() = Unit
fun isPlayServiceAvailavle(context: Context) = false
}