mirror of
https://github.com/nextcloud/talk-android
synced 2025-06-21 04:29:45 +01:00
Merge pull request #2415 from nextcloud/feature/1724/missedCallNotification
Feature/1724/missed call notification
This commit is contained in:
commit
263edbc1d0
@ -39,7 +39,7 @@
|
|||||||
<meta-data android:name="google_analytics_adid_collection_enabled" android:value="false" />
|
<meta-data android:name="google_analytics_adid_collection_enabled" android:value="false" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".services.firebase.ChatAndCallMessagingService"
|
android:name=".services.firebase.NCFirebaseMessagingService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="phoneCall">
|
android:foregroundServiceType="phoneCall">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
@ -1,327 +0,0 @@
|
|||||||
/*
|
|
||||||
* Nextcloud Talk application
|
|
||||||
*
|
|
||||||
* @author Mario Danic
|
|
||||||
* @author Tim Krüger
|
|
||||||
* Copyright (C) 2022 Tim Krüger <t@timkrueger.me>
|
|
||||||
* Copyright (C) 2017-2019 Mario Danic <mario@lovelyhq.com>
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.nextcloud.talk.services.firebase
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Handler
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.emoji.text.EmojiCompat
|
|
||||||
import androidx.work.Data
|
|
||||||
import androidx.work.OneTimeWorkRequest
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import autodagger.AutoInjector
|
|
||||||
import com.bluelinelabs.logansquare.LoganSquare
|
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
|
||||||
import com.google.firebase.messaging.RemoteMessage
|
|
||||||
import com.nextcloud.talk.R
|
|
||||||
import com.nextcloud.talk.activities.CallNotificationActivity
|
|
||||||
import com.nextcloud.talk.api.NcApi
|
|
||||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
|
||||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
|
||||||
import com.nextcloud.talk.events.CallNotificationClick
|
|
||||||
import com.nextcloud.talk.jobs.NotificationWorker
|
|
||||||
import com.nextcloud.talk.jobs.PushRegistrationWorker
|
|
||||||
import com.nextcloud.talk.models.SignatureVerification
|
|
||||||
import com.nextcloud.talk.models.json.participants.Participant
|
|
||||||
import com.nextcloud.talk.models.json.participants.ParticipantsOverall
|
|
||||||
import com.nextcloud.talk.models.json.push.DecryptedPushMessage
|
|
||||||
import com.nextcloud.talk.utils.ApiUtils
|
|
||||||
import com.nextcloud.talk.utils.NotificationUtils
|
|
||||||
import com.nextcloud.talk.utils.NotificationUtils.cancelAllNotificationsForAccount
|
|
||||||
import com.nextcloud.talk.utils.NotificationUtils.cancelExistingNotificationWithId
|
|
||||||
import com.nextcloud.talk.utils.NotificationUtils.getCallRingtoneUri
|
|
||||||
import com.nextcloud.talk.utils.PushUtils
|
|
||||||
import com.nextcloud.talk.utils.bundle.BundleKeys
|
|
||||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FROM_NOTIFICATION_START_CALL
|
|
||||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY
|
|
||||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
|
||||||
import io.reactivex.Observable
|
|
||||||
import io.reactivex.Observer
|
|
||||||
import io.reactivex.disposables.Disposable
|
|
||||||
import io.reactivex.schedulers.Schedulers
|
|
||||||
import okhttp3.JavaNetCookieJar
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.greenrobot.eventbus.EventBus
|
|
||||||
import org.greenrobot.eventbus.Subscribe
|
|
||||||
import org.greenrobot.eventbus.ThreadMode
|
|
||||||
import retrofit2.Retrofit
|
|
||||||
import java.net.CookieManager
|
|
||||||
import java.security.InvalidKeyException
|
|
||||||
import java.security.NoSuchAlgorithmException
|
|
||||||
import java.security.PrivateKey
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.NoSuchPaddingException
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@SuppressLint("LongLogTag")
|
|
||||||
@AutoInjector(NextcloudTalkApplication::class)
|
|
||||||
class ChatAndCallMessagingService : FirebaseMessagingService() {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var appPreferences: AppPreferences
|
|
||||||
|
|
||||||
private var isServiceInForeground: Boolean = false
|
|
||||||
private var decryptedPushMessage: DecryptedPushMessage? = null
|
|
||||||
private var signatureVerification: SignatureVerification? = null
|
|
||||||
private var handler: Handler = Handler()
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var retrofit: Retrofit
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var okHttpClient: OkHttpClient
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var eventBus: EventBus
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
sharedApplication!!.componentApplication.inject(this)
|
|
||||||
eventBus.register(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Subscribe(threadMode = ThreadMode.BACKGROUND)
|
|
||||||
fun onMessageEvent(event: CallNotificationClick) {
|
|
||||||
Log.d(TAG, "CallNotification was clicked")
|
|
||||||
isServiceInForeground = false
|
|
||||||
stopForeground(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
Log.d(TAG, "onDestroy")
|
|
||||||
isServiceInForeground = false
|
|
||||||
eventBus.unregister(this)
|
|
||||||
stopForeground(true)
|
|
||||||
handler.removeCallbacksAndMessages(null)
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNewToken(token: String) {
|
|
||||||
super.onNewToken(token)
|
|
||||||
sharedApplication!!.componentApplication.inject(this)
|
|
||||||
appPreferences.pushToken = token
|
|
||||||
Log.d(TAG, "onNewToken. token = $token")
|
|
||||||
|
|
||||||
val data: Data = Data.Builder().putString(PushRegistrationWorker.ORIGIN, "onNewToken").build()
|
|
||||||
val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java)
|
|
||||||
.setInputData(data)
|
|
||||||
.build()
|
|
||||||
WorkManager.getInstance().enqueue(pushRegistrationWork)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
|
||||||
Log.d(TAG, "onMessageReceived")
|
|
||||||
sharedApplication!!.componentApplication.inject(this)
|
|
||||||
if (!remoteMessage.data["subject"].isNullOrEmpty() && !remoteMessage.data["signature"].isNullOrEmpty()) {
|
|
||||||
decryptMessage(remoteMessage.data["subject"]!!, remoteMessage.data["signature"]!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
|
||||||
private fun decryptMessage(subject: String, signature: String) {
|
|
||||||
try {
|
|
||||||
val base64DecodedSubject = Base64.decode(subject, Base64.DEFAULT)
|
|
||||||
val base64DecodedSignature = Base64.decode(signature, Base64.DEFAULT)
|
|
||||||
val pushUtils = PushUtils()
|
|
||||||
val privateKey = pushUtils.readKeyFromFile(false) as PrivateKey
|
|
||||||
try {
|
|
||||||
signatureVerification = pushUtils.verifySignature(
|
|
||||||
base64DecodedSignature,
|
|
||||||
base64DecodedSubject
|
|
||||||
)
|
|
||||||
if (signatureVerification!!.signatureValid) {
|
|
||||||
decryptMessage(privateKey, base64DecodedSubject, subject, signature)
|
|
||||||
}
|
|
||||||
} catch (e1: NoSuchAlgorithmException) {
|
|
||||||
Log.e(NotificationWorker.TAG, "No proper algorithm to decrypt the message.", e1)
|
|
||||||
} catch (e1: NoSuchPaddingException) {
|
|
||||||
Log.e(NotificationWorker.TAG, "No proper padding to decrypt the message.", e1)
|
|
||||||
} catch (e1: InvalidKeyException) {
|
|
||||||
Log.e(NotificationWorker.TAG, "Invalid private key.", e1)
|
|
||||||
}
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
Log.e(NotificationWorker.TAG, "Something went very wrong!", exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decryptMessage(
|
|
||||||
privateKey: PrivateKey,
|
|
||||||
base64DecodedSubject: ByteArray?,
|
|
||||||
subject: String,
|
|
||||||
signature: String
|
|
||||||
) {
|
|
||||||
val cipher = Cipher.getInstance("RSA/None/PKCS1Padding")
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, privateKey)
|
|
||||||
val decryptedSubject = cipher.doFinal(base64DecodedSubject)
|
|
||||||
decryptedPushMessage = LoganSquare.parse(
|
|
||||||
String(decryptedSubject),
|
|
||||||
DecryptedPushMessage::class.java
|
|
||||||
)
|
|
||||||
decryptedPushMessage?.apply {
|
|
||||||
Log.d(TAG, this.toString())
|
|
||||||
timestamp = System.currentTimeMillis()
|
|
||||||
if (delete) {
|
|
||||||
cancelExistingNotificationWithId(applicationContext, signatureVerification!!.user!!, notificationId)
|
|
||||||
} else if (deleteAll) {
|
|
||||||
cancelAllNotificationsForAccount(applicationContext, signatureVerification!!.user!!)
|
|
||||||
} else if (deleteMultiple) {
|
|
||||||
notificationIds!!.forEach {
|
|
||||||
cancelExistingNotificationWithId(applicationContext, signatureVerification!!.user!!, it)
|
|
||||||
}
|
|
||||||
} else if (type == "call") {
|
|
||||||
val fullScreenIntent = Intent(applicationContext, CallNotificationActivity::class.java)
|
|
||||||
val bundle = Bundle()
|
|
||||||
bundle.putString(BundleKeys.KEY_ROOM_ID, decryptedPushMessage!!.id)
|
|
||||||
bundle.putParcelable(KEY_USER_ENTITY, signatureVerification!!.user)
|
|
||||||
bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, true)
|
|
||||||
fullScreenIntent.putExtras(bundle)
|
|
||||||
|
|
||||||
fullScreenIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
val fullScreenPendingIntent = PendingIntent.getActivity(
|
|
||||||
this@ChatAndCallMessagingService,
|
|
||||||
0,
|
|
||||||
fullScreenIntent,
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
} else {
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val soundUri = getCallRingtoneUri(applicationContext!!, appPreferences)
|
|
||||||
val notificationChannelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_CALLS_V4.name
|
|
||||||
val uri = Uri.parse(signatureVerification!!.user!!.baseUrl)
|
|
||||||
val baseUrl = uri.host
|
|
||||||
|
|
||||||
val notification =
|
|
||||||
NotificationCompat.Builder(this@ChatAndCallMessagingService, notificationChannelId)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
|
||||||
.setSmallIcon(R.drawable.ic_call_black_24dp)
|
|
||||||
.setSubText(baseUrl)
|
|
||||||
.setShowWhen(true)
|
|
||||||
.setWhen(decryptedPushMessage!!.timestamp)
|
|
||||||
.setContentTitle(EmojiCompat.get().process(decryptedPushMessage!!.subject))
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setOngoing(true)
|
|
||||||
// .setTimeoutAfter(45000L)
|
|
||||||
.setContentIntent(fullScreenPendingIntent)
|
|
||||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
|
||||||
.setSound(soundUri)
|
|
||||||
.build()
|
|
||||||
notification.flags = notification.flags or Notification.FLAG_INSISTENT
|
|
||||||
isServiceInForeground = true
|
|
||||||
checkIfCallIsActive(signatureVerification!!, decryptedPushMessage!!)
|
|
||||||
startForeground(decryptedPushMessage!!.timestamp.toInt(), notification)
|
|
||||||
} else {
|
|
||||||
val messageData = Data.Builder()
|
|
||||||
.putString(BundleKeys.KEY_NOTIFICATION_SUBJECT, subject)
|
|
||||||
.putString(BundleKeys.KEY_NOTIFICATION_SIGNATURE, signature)
|
|
||||||
.build()
|
|
||||||
val pushNotificationWork =
|
|
||||||
OneTimeWorkRequest.Builder(NotificationWorker::class.java).setInputData(messageData)
|
|
||||||
.build()
|
|
||||||
WorkManager.getInstance().enqueue(pushNotificationWork)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkIfCallIsActive(
|
|
||||||
signatureVerification: SignatureVerification,
|
|
||||||
decryptedPushMessage: DecryptedPushMessage
|
|
||||||
) {
|
|
||||||
Log.d(TAG, "checkIfCallIsActive")
|
|
||||||
val ncApi = retrofit.newBuilder()
|
|
||||||
.client(okHttpClient.newBuilder().cookieJar(JavaNetCookieJar(CookieManager())).build()).build()
|
|
||||||
.create(NcApi::class.java)
|
|
||||||
var hasParticipantsInCall = true
|
|
||||||
var inCallOnDifferentDevice = false
|
|
||||||
|
|
||||||
val apiVersion = ApiUtils.getConversationApiVersion(
|
|
||||||
signatureVerification.user,
|
|
||||||
intArrayOf(ApiUtils.APIv4, 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
ncApi.getPeersForCall(
|
|
||||||
ApiUtils.getCredentials(
|
|
||||||
signatureVerification.user!!.username,
|
|
||||||
signatureVerification.user!!.token
|
|
||||||
),
|
|
||||||
ApiUtils.getUrlForCall(
|
|
||||||
apiVersion,
|
|
||||||
signatureVerification.user!!.baseUrl,
|
|
||||||
decryptedPushMessage.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.repeatWhen { completed ->
|
|
||||||
completed.zipWith(Observable.range(1, OBSERVABLE_COUNT), { _, i -> i })
|
|
||||||
.flatMap { Observable.timer(OBSERVABLE_DELAY, TimeUnit.SECONDS) }
|
|
||||||
.takeWhile { isServiceInForeground && hasParticipantsInCall && !inCallOnDifferentDevice }
|
|
||||||
}
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.subscribe(object : Observer<ParticipantsOverall> {
|
|
||||||
override fun onSubscribe(d: Disposable) = Unit
|
|
||||||
|
|
||||||
override fun onNext(participantsOverall: ParticipantsOverall) {
|
|
||||||
val participantList: List<Participant> = participantsOverall.ocs!!.data!!
|
|
||||||
hasParticipantsInCall = participantList.isNotEmpty()
|
|
||||||
if (hasParticipantsInCall) {
|
|
||||||
for (participant in participantList) {
|
|
||||||
if (participant.actorId == signatureVerification.user!!.userId &&
|
|
||||||
participant.actorType == Participant.ActorType.USERS
|
|
||||||
) {
|
|
||||||
inCallOnDifferentDevice = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!hasParticipantsInCall || inCallOnDifferentDevice) {
|
|
||||||
Log.d(TAG, "no participants in call OR inCallOnDifferentDevice")
|
|
||||||
stopForeground(true)
|
|
||||||
handler.removeCallbacksAndMessages(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(e: Throwable) = Unit
|
|
||||||
|
|
||||||
override fun onComplete() {
|
|
||||||
stopForeground(true)
|
|
||||||
handler.removeCallbacksAndMessages(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = ChatAndCallMessagingService::class.simpleName
|
|
||||||
private const val OBSERVABLE_COUNT = 12
|
|
||||||
private const val OBSERVABLE_DELAY: Long = 5
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* Nextcloud Talk application
|
||||||
|
*
|
||||||
|
* @author Mario Danic
|
||||||
|
* @author Tim Krüger
|
||||||
|
* @author Marcel Hibbe
|
||||||
|
* Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||||
|
* Copyright (C) 2022 Tim Krüger <t@timkrueger.me>
|
||||||
|
* Copyright (C) 2017-2019 Mario Danic <mario@lovelyhq.com>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.nextcloud.talk.services.firebase
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.OneTimeWorkRequest
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import autodagger.AutoInjector
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||||
|
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||||
|
import com.nextcloud.talk.jobs.NotificationWorker
|
||||||
|
import com.nextcloud.talk.jobs.PushRegistrationWorker
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys
|
||||||
|
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AutoInjector(NextcloudTalkApplication::class)
|
||||||
|
class NCFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var appPreferences: AppPreferences
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
Log.d(TAG, "onCreate")
|
||||||
|
super.onCreate()
|
||||||
|
sharedApplication!!.componentApplication.inject(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||||
|
Log.d(TAG, "onMessageReceived")
|
||||||
|
sharedApplication!!.componentApplication.inject(this)
|
||||||
|
|
||||||
|
Log.d(TAG, "remoteMessage.priority: " + remoteMessage.priority)
|
||||||
|
Log.d(TAG, "remoteMessage.originalPriority: " + remoteMessage.originalPriority)
|
||||||
|
|
||||||
|
val data = remoteMessage.data
|
||||||
|
val subject = data[KEY_NOTIFICATION_SUBJECT]
|
||||||
|
val signature = data[KEY_NOTIFICATION_SIGNATURE]
|
||||||
|
|
||||||
|
if (!subject.isNullOrEmpty() && !signature.isNullOrEmpty()) {
|
||||||
|
val messageData = Data.Builder()
|
||||||
|
.putString(BundleKeys.KEY_NOTIFICATION_SUBJECT, subject)
|
||||||
|
.putString(BundleKeys.KEY_NOTIFICATION_SIGNATURE, signature)
|
||||||
|
.build()
|
||||||
|
val notificationWork =
|
||||||
|
OneTimeWorkRequest.Builder(NotificationWorker::class.java).setInputData(messageData)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance().enqueue(notificationWork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
super.onNewToken(token)
|
||||||
|
Log.d(TAG, "onNewToken. token = $token")
|
||||||
|
|
||||||
|
appPreferences.pushToken = token
|
||||||
|
|
||||||
|
val data: Data = Data.Builder().putString(
|
||||||
|
PushRegistrationWorker.ORIGIN,
|
||||||
|
"NCFirebaseMessagingService#onNewToken"
|
||||||
|
).build()
|
||||||
|
val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java)
|
||||||
|
.setInputData(data)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance().enqueue(pushRegistrationWork)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = NCFirebaseMessagingService::class.simpleName
|
||||||
|
const val KEY_NOTIFICATION_SUBJECT = "subject"
|
||||||
|
const val KEY_NOTIFICATION_SIGNATURE = "signature"
|
||||||
|
}
|
||||||
|
}
|
@ -1,451 +0,0 @@
|
|||||||
/*
|
|
||||||
* Nextcloud Talk application
|
|
||||||
*
|
|
||||||
* @author Mario Danic
|
|
||||||
* Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.nextcloud.talk.activities;
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.res.Configuration;
|
|
||||||
import android.graphics.Bitmap;
|
|
||||||
import android.graphics.drawable.BitmapDrawable;
|
|
||||||
import android.media.AudioAttributes;
|
|
||||||
import android.media.MediaPlayer;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.os.Handler;
|
|
||||||
import android.util.Log;
|
|
||||||
import android.view.View;
|
|
||||||
|
|
||||||
import com.facebook.common.executors.UiThreadImmediateExecutorService;
|
|
||||||
import com.facebook.common.references.CloseableReference;
|
|
||||||
import com.facebook.datasource.DataSource;
|
|
||||||
import com.facebook.drawee.backends.pipeline.Fresco;
|
|
||||||
import com.facebook.imagepipeline.core.ImagePipeline;
|
|
||||||
import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber;
|
|
||||||
import com.facebook.imagepipeline.image.CloseableImage;
|
|
||||||
import com.facebook.imagepipeline.request.ImageRequest;
|
|
||||||
import com.nextcloud.talk.R;
|
|
||||||
import com.nextcloud.talk.api.NcApi;
|
|
||||||
import com.nextcloud.talk.application.NextcloudTalkApplication;
|
|
||||||
import com.nextcloud.talk.data.user.model.User;
|
|
||||||
import com.nextcloud.talk.databinding.CallNotificationActivityBinding;
|
|
||||||
import com.nextcloud.talk.events.CallNotificationClick;
|
|
||||||
import com.nextcloud.talk.models.json.conversations.Conversation;
|
|
||||||
import com.nextcloud.talk.models.json.conversations.RoomOverall;
|
|
||||||
import com.nextcloud.talk.models.json.participants.Participant;
|
|
||||||
import com.nextcloud.talk.models.json.participants.ParticipantsOverall;
|
|
||||||
import com.nextcloud.talk.utils.ApiUtils;
|
|
||||||
import com.nextcloud.talk.utils.DisplayUtils;
|
|
||||||
import com.nextcloud.talk.utils.DoNotDisturbUtils;
|
|
||||||
import com.nextcloud.talk.utils.NotificationUtils;
|
|
||||||
import com.nextcloud.talk.utils.ParticipantPermissions;
|
|
||||||
import com.nextcloud.talk.utils.bundle.BundleKeys;
|
|
||||||
import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew;
|
|
||||||
import com.nextcloud.talk.utils.preferences.AppPreferences;
|
|
||||||
|
|
||||||
import org.greenrobot.eventbus.EventBus;
|
|
||||||
import org.parceler.Parcels;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import javax.inject.Inject;
|
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.annotation.RequiresApi;
|
|
||||||
import autodagger.AutoInjector;
|
|
||||||
import io.reactivex.Observable;
|
|
||||||
import io.reactivex.Observer;
|
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
|
||||||
import io.reactivex.annotations.NonNull;
|
|
||||||
import io.reactivex.disposables.Disposable;
|
|
||||||
import io.reactivex.schedulers.Schedulers;
|
|
||||||
import okhttp3.Cache;
|
|
||||||
|
|
||||||
@SuppressLint("LongLogTag")
|
|
||||||
@AutoInjector(NextcloudTalkApplication.class)
|
|
||||||
public class CallNotificationActivity extends CallBaseActivity {
|
|
||||||
|
|
||||||
public static final String TAG = "CallNotificationActivity";
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
NcApi ncApi;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
AppPreferences appPreferences;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
Cache cache;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
EventBus eventBus;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
Context context;
|
|
||||||
|
|
||||||
private List<Disposable> disposablesList = new ArrayList<>();
|
|
||||||
private Bundle originalBundle;
|
|
||||||
private String roomId;
|
|
||||||
private User userBeingCalled;
|
|
||||||
private String credentials;
|
|
||||||
private Conversation currentConversation;
|
|
||||||
private MediaPlayer mediaPlayer;
|
|
||||||
private boolean leavingScreen = false;
|
|
||||||
private Handler handler;
|
|
||||||
private CallNotificationActivityBinding binding;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
|
||||||
Log.d(TAG, "onCreate");
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
|
|
||||||
NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this);
|
|
||||||
|
|
||||||
binding = CallNotificationActivityBinding.inflate(getLayoutInflater());
|
|
||||||
setContentView(binding.getRoot());
|
|
||||||
|
|
||||||
hideNavigationIfNoPipAvailable();
|
|
||||||
|
|
||||||
eventBus.post(new CallNotificationClick());
|
|
||||||
|
|
||||||
Bundle extras = getIntent().getExtras();
|
|
||||||
this.roomId = extras.getString(BundleKeys.KEY_ROOM_ID, "");
|
|
||||||
this.currentConversation = Parcels.unwrap(extras.getParcelable(BundleKeys.KEY_ROOM));
|
|
||||||
this.userBeingCalled = extras.getParcelable(BundleKeys.KEY_USER_ENTITY);
|
|
||||||
|
|
||||||
this.originalBundle = extras;
|
|
||||||
credentials = ApiUtils.getCredentials(userBeingCalled.getUsername(), userBeingCalled.getToken());
|
|
||||||
|
|
||||||
setCallDescriptionText();
|
|
||||||
|
|
||||||
if (currentConversation == null) {
|
|
||||||
handleFromNotification();
|
|
||||||
} else {
|
|
||||||
setUpAfterConversationIsKnown();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DoNotDisturbUtils.INSTANCE.shouldPlaySound()) {
|
|
||||||
playRingtoneSound();
|
|
||||||
}
|
|
||||||
|
|
||||||
initClickListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStart() {
|
|
||||||
super.onStart();
|
|
||||||
|
|
||||||
if (handler == null) {
|
|
||||||
handler = new Handler();
|
|
||||||
|
|
||||||
try {
|
|
||||||
cache.evictAll();
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.e(TAG, "Failed to evict cache");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void initClickListeners() {
|
|
||||||
binding.callAnswerVoiceOnlyView.setOnClickListener(l -> {
|
|
||||||
Log.d(TAG, "accept call (voice only)");
|
|
||||||
originalBundle.putBoolean(BundleKeys.KEY_CALL_VOICE_ONLY, true);
|
|
||||||
proceedToCall();
|
|
||||||
});
|
|
||||||
|
|
||||||
binding.callAnswerCameraView.setOnClickListener(l -> {
|
|
||||||
Log.d(TAG, "accept call (with video)");
|
|
||||||
originalBundle.putBoolean(BundleKeys.KEY_CALL_VOICE_ONLY, false);
|
|
||||||
proceedToCall();
|
|
||||||
});
|
|
||||||
|
|
||||||
binding.hangupButton.setOnClickListener(l -> hangup());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setCallDescriptionText() {
|
|
||||||
String callDescriptionWithoutTypeInfo =
|
|
||||||
String.format(
|
|
||||||
getResources().getString(R.string.nc_call_unknown),
|
|
||||||
getResources().getString(R.string.nc_app_product_name));
|
|
||||||
|
|
||||||
binding.incomingCallVoiceOrVideoTextView.setText(callDescriptionWithoutTypeInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showAnswerControls() {
|
|
||||||
binding.callAnswerCameraView.setVisibility(View.VISIBLE);
|
|
||||||
binding.callAnswerVoiceOnlyView.setVisibility(View.VISIBLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void hangup() {
|
|
||||||
leavingScreen = true;
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void proceedToCall() {
|
|
||||||
originalBundle.putString(BundleKeys.KEY_ROOM_TOKEN, currentConversation.getToken());
|
|
||||||
originalBundle.putString(BundleKeys.KEY_CONVERSATION_NAME, currentConversation.getDisplayName());
|
|
||||||
|
|
||||||
ParticipantPermissions participantPermission = new ParticipantPermissions(userBeingCalled, currentConversation);
|
|
||||||
originalBundle.putBoolean(
|
|
||||||
BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_AUDIO,
|
|
||||||
participantPermission.canPublishAudio());
|
|
||||||
originalBundle.putBoolean(
|
|
||||||
BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO,
|
|
||||||
participantPermission.canPublishVideo());
|
|
||||||
|
|
||||||
Intent intent = new Intent(this, CallActivity.class);
|
|
||||||
intent.putExtras(originalBundle);
|
|
||||||
startActivity(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkIfAnyParticipantsRemainInRoom() {
|
|
||||||
int apiVersion = ApiUtils.getCallApiVersion(userBeingCalled, new int[]{ApiUtils.APIv4, 1});
|
|
||||||
|
|
||||||
ncApi.getPeersForCall(
|
|
||||||
credentials,
|
|
||||||
ApiUtils.getUrlForCall(
|
|
||||||
apiVersion,
|
|
||||||
userBeingCalled.getBaseUrl(),
|
|
||||||
currentConversation.getToken()))
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.repeatWhen(completed -> completed.zipWith(Observable.range(1, 12), (n, i) -> i)
|
|
||||||
.flatMap(retryCount -> Observable.timer(5, TimeUnit.SECONDS))
|
|
||||||
.takeWhile(observable -> !leavingScreen))
|
|
||||||
.subscribe(new Observer<ParticipantsOverall>() {
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(@NonNull Disposable d) {
|
|
||||||
disposablesList.add(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(@NonNull ParticipantsOverall participantsOverall) {
|
|
||||||
boolean hasParticipantsInCall = false;
|
|
||||||
boolean inCallOnDifferentDevice = false;
|
|
||||||
List<Participant> participantList = participantsOverall.getOcs().getData();
|
|
||||||
hasParticipantsInCall = participantList.size() > 0;
|
|
||||||
|
|
||||||
if (hasParticipantsInCall) {
|
|
||||||
for (Participant participant : participantList) {
|
|
||||||
if (participant.getCalculatedActorType() == Participant.ActorType.USERS &&
|
|
||||||
participant.getCalculatedActorId().equals(userBeingCalled.getUserId())) {
|
|
||||||
inCallOnDifferentDevice = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasParticipantsInCall || inCallOnDifferentDevice) {
|
|
||||||
runOnUiThread(() -> hangup());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(@NonNull Throwable e) {
|
|
||||||
Log.e(TAG, "error while getPeersForCall", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
runOnUiThread(() -> hangup());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleFromNotification() {
|
|
||||||
int apiVersion = ApiUtils.getConversationApiVersion(userBeingCalled, new int[]{ApiUtils.APIv4,
|
|
||||||
ApiUtils.APIv3, 1});
|
|
||||||
|
|
||||||
ncApi.getRoom(credentials, ApiUtils.getUrlForRoom(apiVersion, userBeingCalled.getBaseUrl(), roomId))
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.retry(3)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(new Observer<RoomOverall>() {
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(@NonNull Disposable d) {
|
|
||||||
disposablesList.add(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(@NonNull RoomOverall roomOverall) {
|
|
||||||
currentConversation = roomOverall.getOcs().getData();
|
|
||||||
setUpAfterConversationIsKnown();
|
|
||||||
|
|
||||||
if (apiVersion >= 3) {
|
|
||||||
boolean hasCallFlags =
|
|
||||||
CapabilitiesUtilNew.hasSpreedFeatureCapability(userBeingCalled,
|
|
||||||
"conversation-call-flags");
|
|
||||||
if (hasCallFlags) {
|
|
||||||
if (isInCallWithVideo(currentConversation.getCallFlag())) {
|
|
||||||
binding.incomingCallVoiceOrVideoTextView.setText(
|
|
||||||
String.format(getResources().getString(R.string.nc_call_video),
|
|
||||||
getResources().getString(R.string.nc_app_product_name)));
|
|
||||||
} else {
|
|
||||||
binding.incomingCallVoiceOrVideoTextView.setText(
|
|
||||||
String.format(getResources().getString(R.string.nc_call_voice),
|
|
||||||
getResources().getString(R.string.nc_app_product_name)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(@NonNull Throwable e) {
|
|
||||||
Log.e(TAG, e.getMessage(), e);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
// unused atm
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isInCallWithVideo(int callFlag) {
|
|
||||||
return (callFlag >= Participant.InCallFlags.IN_CALL + Participant.InCallFlags.WITH_VIDEO);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setUpAfterConversationIsKnown() {
|
|
||||||
binding.conversationNameTextView.setText(currentConversation.getDisplayName());
|
|
||||||
|
|
||||||
if(currentConversation.getType() == Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL){
|
|
||||||
setAvatarForOneToOneCall();
|
|
||||||
} else {
|
|
||||||
binding.avatarImageView.setImageResource(R.drawable.ic_circular_group);
|
|
||||||
}
|
|
||||||
|
|
||||||
checkIfAnyParticipantsRemainInRoom();
|
|
||||||
showAnswerControls();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setAvatarForOneToOneCall() {
|
|
||||||
ImageRequest imageRequest =
|
|
||||||
DisplayUtils.getImageRequestForUrl(
|
|
||||||
ApiUtils.getUrlForAvatar(userBeingCalled.getBaseUrl(),
|
|
||||||
currentConversation.getName(),
|
|
||||||
true));
|
|
||||||
|
|
||||||
ImagePipeline imagePipeline = Fresco.getImagePipeline();
|
|
||||||
DataSource<CloseableReference<CloseableImage>> dataSource = imagePipeline.fetchDecodedImage(imageRequest, null);
|
|
||||||
|
|
||||||
dataSource.subscribe(new BaseBitmapDataSubscriber() {
|
|
||||||
@Override
|
|
||||||
protected void onNewResultImpl(@Nullable Bitmap bitmap) {
|
|
||||||
binding.avatarImageView.getHierarchy().setImage(
|
|
||||||
new BitmapDrawable(getResources(), bitmap),
|
|
||||||
100,
|
|
||||||
true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onFailureImpl(DataSource<CloseableReference<CloseableImage>> dataSource) {
|
|
||||||
Log.e(TAG, "failed to load avatar");
|
|
||||||
}
|
|
||||||
}, UiThreadImmediateExecutorService.getInstance());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void endMediaNotifications() {
|
|
||||||
if (mediaPlayer != null) {
|
|
||||||
if (mediaPlayer.isPlaying()) {
|
|
||||||
mediaPlayer.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaPlayer.release();
|
|
||||||
mediaPlayer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroy() {
|
|
||||||
leavingScreen = true;
|
|
||||||
if (handler != null) {
|
|
||||||
handler.removeCallbacksAndMessages(null);
|
|
||||||
handler = null;
|
|
||||||
}
|
|
||||||
dispose();
|
|
||||||
endMediaNotifications();
|
|
||||||
super.onDestroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void dispose() {
|
|
||||||
if (disposablesList != null) {
|
|
||||||
for (Disposable disposable : disposablesList) {
|
|
||||||
if (!disposable.isDisposed()) {
|
|
||||||
disposable.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void playRingtoneSound() {
|
|
||||||
Uri ringtoneUri = NotificationUtils.INSTANCE.getCallRingtoneUri(getApplicationContext(), appPreferences);
|
|
||||||
if (ringtoneUri != null) {
|
|
||||||
mediaPlayer = new MediaPlayer();
|
|
||||||
try {
|
|
||||||
mediaPlayer.setDataSource(this, ringtoneUri);
|
|
||||||
|
|
||||||
mediaPlayer.setLooping(true);
|
|
||||||
AudioAttributes audioAttributes = new AudioAttributes
|
|
||||||
.Builder()
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
|
||||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
|
||||||
.build();
|
|
||||||
mediaPlayer.setAudioAttributes(audioAttributes);
|
|
||||||
|
|
||||||
mediaPlayer.setOnPreparedListener(mp -> mediaPlayer.start());
|
|
||||||
|
|
||||||
mediaPlayer.prepareAsync();
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.e(TAG, "Failed to set data source");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
|
||||||
@Override
|
|
||||||
public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) {
|
|
||||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
|
|
||||||
isInPipMode = isInPictureInPictureMode;
|
|
||||||
if (isInPictureInPictureMode) {
|
|
||||||
updateUiForPipMode();
|
|
||||||
} else {
|
|
||||||
updateUiForNormalMode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void updateUiForPipMode() {
|
|
||||||
binding.callAnswerButtons.setVisibility(View.INVISIBLE);
|
|
||||||
binding.incomingCallRelativeLayout.setVisibility(View.INVISIBLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void updateUiForNormalMode() {
|
|
||||||
binding.callAnswerButtons.setVisibility(View.VISIBLE);
|
|
||||||
binding.incomingCallRelativeLayout.setVisibility(View.VISIBLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
void suppressFitsSystemWindows() {
|
|
||||||
binding.controllerCallNotificationLayout.setFitsSystemWindows(false);
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,476 @@
|
|||||||
|
/*
|
||||||
|
* Nextcloud Talk application
|
||||||
|
*
|
||||||
|
* @author Mario Danic
|
||||||
|
* @author Marcel Hibbe
|
||||||
|
* Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||||
|
* Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.nextcloud.talk.activities
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import autodagger.AutoInjector
|
||||||
|
import com.facebook.common.executors.UiThreadImmediateExecutorService
|
||||||
|
import com.facebook.common.references.CloseableReference
|
||||||
|
import com.facebook.datasource.DataSource
|
||||||
|
import com.facebook.drawee.backends.pipeline.Fresco
|
||||||
|
import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber
|
||||||
|
import com.facebook.imagepipeline.image.CloseableImage
|
||||||
|
import com.nextcloud.talk.R
|
||||||
|
import com.nextcloud.talk.api.NcApi
|
||||||
|
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||||
|
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||||
|
import com.nextcloud.talk.data.user.model.User
|
||||||
|
import com.nextcloud.talk.databinding.CallNotificationActivityBinding
|
||||||
|
import com.nextcloud.talk.models.json.conversations.Conversation
|
||||||
|
import com.nextcloud.talk.models.json.conversations.RoomOverall
|
||||||
|
import com.nextcloud.talk.models.json.participants.Participant
|
||||||
|
import com.nextcloud.talk.models.json.participants.ParticipantsOverall
|
||||||
|
import com.nextcloud.talk.utils.ApiUtils
|
||||||
|
import com.nextcloud.talk.utils.DisplayUtils
|
||||||
|
import com.nextcloud.talk.utils.DoNotDisturbUtils.shouldPlaySound
|
||||||
|
import com.nextcloud.talk.utils.NotificationUtils
|
||||||
|
import com.nextcloud.talk.utils.NotificationUtils.getCallRingtoneUri
|
||||||
|
import com.nextcloud.talk.utils.ParticipantPermissions
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY
|
||||||
|
import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew.hasSpreedFeatureCapability
|
||||||
|
import io.reactivex.Observable
|
||||||
|
import io.reactivex.Observer
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.disposables.Disposable
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import okhttp3.Cache
|
||||||
|
import org.parceler.Parcels
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@SuppressLint("LongLogTag")
|
||||||
|
@AutoInjector(NextcloudTalkApplication::class)
|
||||||
|
class CallNotificationActivity : CallBaseActivity() {
|
||||||
|
@JvmField
|
||||||
|
@Inject
|
||||||
|
var ncApi: NcApi? = null
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
@Inject
|
||||||
|
var cache: Cache? = null
|
||||||
|
|
||||||
|
private val disposablesList: MutableList<Disposable> = ArrayList()
|
||||||
|
private var originalBundle: Bundle? = null
|
||||||
|
private var roomToken: String? = null
|
||||||
|
private var userBeingCalled: User? = null
|
||||||
|
private var credentials: String? = null
|
||||||
|
private var currentConversation: Conversation? = null
|
||||||
|
private var mediaPlayer: MediaPlayer? = null
|
||||||
|
private var leavingScreen = false
|
||||||
|
private var handler: Handler? = null
|
||||||
|
private var binding: CallNotificationActivityBinding? = null
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
Log.d(TAG, "onCreate")
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
sharedApplication!!.componentApplication.inject(this)
|
||||||
|
binding = CallNotificationActivityBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding!!.root)
|
||||||
|
hideNavigationIfNoPipAvailable()
|
||||||
|
val extras = intent.extras
|
||||||
|
roomToken = extras!!.getString(KEY_ROOM_TOKEN, "")
|
||||||
|
currentConversation = Parcels.unwrap(extras.getParcelable(KEY_ROOM))
|
||||||
|
userBeingCalled = extras.getParcelable(KEY_USER_ENTITY)
|
||||||
|
originalBundle = extras
|
||||||
|
credentials = ApiUtils.getCredentials(userBeingCalled!!.username, userBeingCalled!!.token)
|
||||||
|
setCallDescriptionText()
|
||||||
|
if (currentConversation == null) {
|
||||||
|
handleFromNotification()
|
||||||
|
} else {
|
||||||
|
setUpAfterConversationIsKnown()
|
||||||
|
}
|
||||||
|
if (shouldPlaySound()) {
|
||||||
|
playRingtoneSound()
|
||||||
|
}
|
||||||
|
initClickListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
if (handler == null) {
|
||||||
|
handler = Handler()
|
||||||
|
try {
|
||||||
|
cache!!.evictAll()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Failed to evict cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initClickListeners() {
|
||||||
|
binding!!.callAnswerVoiceOnlyView.setOnClickListener {
|
||||||
|
Log.d(TAG, "accept call (voice only)")
|
||||||
|
originalBundle!!.putBoolean(KEY_CALL_VOICE_ONLY, true)
|
||||||
|
proceedToCall()
|
||||||
|
}
|
||||||
|
binding!!.callAnswerCameraView.setOnClickListener {
|
||||||
|
Log.d(TAG, "accept call (with video)")
|
||||||
|
originalBundle!!.putBoolean(KEY_CALL_VOICE_ONLY, false)
|
||||||
|
proceedToCall()
|
||||||
|
}
|
||||||
|
binding!!.hangupButton.setOnClickListener { hangup() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCallDescriptionText() {
|
||||||
|
val callDescriptionWithoutTypeInfo = String.format(
|
||||||
|
resources.getString(R.string.nc_call_unknown),
|
||||||
|
resources.getString(R.string.nc_app_product_name)
|
||||||
|
)
|
||||||
|
binding!!.incomingCallVoiceOrVideoTextView.text = callDescriptionWithoutTypeInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showAnswerControls() {
|
||||||
|
binding!!.callAnswerCameraView.visibility = View.VISIBLE
|
||||||
|
binding!!.callAnswerVoiceOnlyView.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hangup() {
|
||||||
|
leavingScreen = true
|
||||||
|
dispose()
|
||||||
|
endMediaNotifications()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun proceedToCall() {
|
||||||
|
originalBundle!!.putString(KEY_ROOM_TOKEN, currentConversation!!.token)
|
||||||
|
originalBundle!!.putString(KEY_CONVERSATION_NAME, currentConversation!!.displayName)
|
||||||
|
|
||||||
|
val participantPermission = ParticipantPermissions(
|
||||||
|
userBeingCalled!!,
|
||||||
|
currentConversation!!
|
||||||
|
)
|
||||||
|
originalBundle!!.putBoolean(
|
||||||
|
BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_AUDIO,
|
||||||
|
participantPermission.canPublishAudio()
|
||||||
|
)
|
||||||
|
originalBundle!!.putBoolean(
|
||||||
|
BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO,
|
||||||
|
participantPermission.canPublishVideo()
|
||||||
|
)
|
||||||
|
|
||||||
|
val intent = Intent(this, CallActivity::class.java)
|
||||||
|
intent.putExtras(originalBundle!!)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkIfAnyParticipantsRemainInRoom() {
|
||||||
|
val apiVersion = ApiUtils.getCallApiVersion(userBeingCalled, intArrayOf(ApiUtils.APIv4, 1))
|
||||||
|
ncApi!!.getPeersForCall(
|
||||||
|
credentials,
|
||||||
|
ApiUtils.getUrlForCall(
|
||||||
|
apiVersion,
|
||||||
|
userBeingCalled!!.baseUrl,
|
||||||
|
currentConversation!!.token
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.repeatWhen { completed: Observable<Any?> ->
|
||||||
|
completed.zipWith(Observable.range(TIMER_START, TIMER_COUNT)) { _: Any?, i: Int? -> i!! }
|
||||||
|
.flatMap { Observable.timer(TIMER_DELAY, TimeUnit.SECONDS) }
|
||||||
|
.takeWhile { !leavingScreen }
|
||||||
|
}
|
||||||
|
.subscribe(object : Observer<ParticipantsOverall> {
|
||||||
|
override fun onSubscribe(d: Disposable) {
|
||||||
|
disposablesList.add(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNext(participantsOverall: ParticipantsOverall) {
|
||||||
|
val hasParticipantsInCall: Boolean
|
||||||
|
var inCallOnDifferentDevice = false
|
||||||
|
val participantList = participantsOverall.ocs!!.data
|
||||||
|
hasParticipantsInCall = participantList!!.isNotEmpty()
|
||||||
|
if (hasParticipantsInCall) {
|
||||||
|
for (participant in participantList) {
|
||||||
|
if (participant.calculatedActorType === Participant.ActorType.USERS &&
|
||||||
|
participant.calculatedActorId == userBeingCalled!!.userId
|
||||||
|
) {
|
||||||
|
inCallOnDifferentDevice = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inCallOnDifferentDevice) {
|
||||||
|
runOnUiThread { hangup() }
|
||||||
|
}
|
||||||
|
if (!hasParticipantsInCall) {
|
||||||
|
showMissedCallNotification()
|
||||||
|
runOnUiThread { hangup() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(e: Throwable) {
|
||||||
|
Log.e(TAG, "error while getPeersForCall", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onComplete() {
|
||||||
|
showMissedCallNotification()
|
||||||
|
runOnUiThread { hangup() }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showMissedCallNotification() {
|
||||||
|
val mNotifyManager: NotificationManager?
|
||||||
|
val mBuilder: NotificationCompat.Builder?
|
||||||
|
|
||||||
|
mNotifyManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
mBuilder = NotificationCompat.Builder(
|
||||||
|
context,
|
||||||
|
NotificationUtils.NotificationChannels
|
||||||
|
.NOTIFICATION_CHANNEL_MESSAGES_V4.name
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification: Notification = mBuilder
|
||||||
|
.setContentTitle(
|
||||||
|
String.format(resources.getString(R.string.nc_missed_call), currentConversation!!.displayName)
|
||||||
|
)
|
||||||
|
.setSmallIcon(R.drawable.ic_baseline_phone_missed_24)
|
||||||
|
.setOngoing(false)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setContentIntent(getIntentToOpenConversation())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val notificationId: Int = SystemClock.uptimeMillis().toInt()
|
||||||
|
mNotifyManager.notify(notificationId, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getIntentToOpenConversation(): PendingIntent? {
|
||||||
|
val bundle = Bundle()
|
||||||
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
|
bundle.putString(KEY_ROOM_TOKEN, currentConversation?.token)
|
||||||
|
bundle.putParcelable(KEY_USER_ENTITY, userBeingCalled)
|
||||||
|
bundle.putBoolean(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL, false)
|
||||||
|
|
||||||
|
intent.putExtras(bundle)
|
||||||
|
|
||||||
|
val requestCode = System.currentTimeMillis().toInt()
|
||||||
|
val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
PendingIntent.FLAG_MUTABLE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(context, requestCode, intent, intentFlag)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
private fun handleFromNotification() {
|
||||||
|
val apiVersion = ApiUtils.getConversationApiVersion(
|
||||||
|
userBeingCalled,
|
||||||
|
intArrayOf(
|
||||||
|
ApiUtils.APIv4,
|
||||||
|
ApiUtils.APIv3, 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ncApi!!.getRoom(credentials, ApiUtils.getUrlForRoom(apiVersion, userBeingCalled!!.baseUrl, roomToken))
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.retry(GET_ROOM_RETRY_COUNT)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(object : Observer<RoomOverall> {
|
||||||
|
override fun onSubscribe(d: Disposable) {
|
||||||
|
disposablesList.add(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNext(roomOverall: RoomOverall) {
|
||||||
|
currentConversation = roomOverall.ocs!!.data
|
||||||
|
setUpAfterConversationIsKnown()
|
||||||
|
if (apiVersion >= 3) {
|
||||||
|
val hasCallFlags = hasSpreedFeatureCapability(
|
||||||
|
userBeingCalled,
|
||||||
|
"conversation-call-flags"
|
||||||
|
)
|
||||||
|
if (hasCallFlags) {
|
||||||
|
if (isInCallWithVideo(currentConversation!!.callFlag)) {
|
||||||
|
binding!!.incomingCallVoiceOrVideoTextView.text = String.format(
|
||||||
|
resources.getString(R.string.nc_call_video),
|
||||||
|
resources.getString(R.string.nc_app_product_name)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
binding!!.incomingCallVoiceOrVideoTextView.text = String.format(
|
||||||
|
resources.getString(R.string.nc_call_voice),
|
||||||
|
resources.getString(R.string.nc_app_product_name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(e: Throwable) {
|
||||||
|
Log.e(TAG, e.message, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onComplete() {
|
||||||
|
// unused atm
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isInCallWithVideo(callFlag: Int): Boolean {
|
||||||
|
return callFlag >= Participant.InCallFlags.IN_CALL + Participant.InCallFlags.WITH_VIDEO
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setUpAfterConversationIsKnown() {
|
||||||
|
binding!!.conversationNameTextView.text = currentConversation!!.displayName
|
||||||
|
if (currentConversation!!.type === Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
|
||||||
|
setAvatarForOneToOneCall()
|
||||||
|
} else {
|
||||||
|
binding!!.avatarImageView.setImageResource(R.drawable.ic_circular_group)
|
||||||
|
}
|
||||||
|
checkIfAnyParticipantsRemainInRoom()
|
||||||
|
showAnswerControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
private fun setAvatarForOneToOneCall() {
|
||||||
|
val imageRequest = DisplayUtils.getImageRequestForUrl(
|
||||||
|
ApiUtils.getUrlForAvatar(
|
||||||
|
userBeingCalled!!.baseUrl,
|
||||||
|
currentConversation!!.name,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val imagePipeline = Fresco.getImagePipeline()
|
||||||
|
val dataSource = imagePipeline.fetchDecodedImage(imageRequest, null)
|
||||||
|
dataSource.subscribe(
|
||||||
|
object : BaseBitmapDataSubscriber() {
|
||||||
|
override fun onNewResultImpl(bitmap: Bitmap?) {
|
||||||
|
binding!!.avatarImageView.hierarchy.setImage(
|
||||||
|
BitmapDrawable(resources, bitmap), 100f,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailureImpl(dataSource: DataSource<CloseableReference<CloseableImage?>>) {
|
||||||
|
Log.e(TAG, "failed to load avatar")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
UiThreadImmediateExecutorService.getInstance()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun endMediaNotifications() {
|
||||||
|
if (mediaPlayer != null) {
|
||||||
|
if (mediaPlayer!!.isPlaying) {
|
||||||
|
mediaPlayer!!.stop()
|
||||||
|
}
|
||||||
|
mediaPlayer!!.release()
|
||||||
|
mediaPlayer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override fun onDestroy() {
|
||||||
|
leavingScreen = true
|
||||||
|
if (handler != null) {
|
||||||
|
handler!!.removeCallbacksAndMessages(null)
|
||||||
|
handler = null
|
||||||
|
}
|
||||||
|
dispose()
|
||||||
|
endMediaNotifications()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dispose() {
|
||||||
|
for (disposable in disposablesList) {
|
||||||
|
if (!disposable.isDisposed) {
|
||||||
|
disposable.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playRingtoneSound() {
|
||||||
|
val ringtoneUri = getCallRingtoneUri(applicationContext, appPreferences)
|
||||||
|
if (ringtoneUri != null) {
|
||||||
|
mediaPlayer = MediaPlayer()
|
||||||
|
try {
|
||||||
|
mediaPlayer!!.setDataSource(this, ringtoneUri)
|
||||||
|
mediaPlayer!!.isLooping = true
|
||||||
|
val audioAttributes = AudioAttributes.Builder()
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||||
|
.build()
|
||||||
|
mediaPlayer!!.setAudioAttributes(audioAttributes)
|
||||||
|
mediaPlayer!!.setOnPreparedListener { mediaPlayer!!.start() }
|
||||||
|
mediaPlayer!!.prepareAsync()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Failed to set data source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||||
|
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||||
|
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||||
|
isInPipMode = isInPictureInPictureMode
|
||||||
|
if (isInPictureInPictureMode) {
|
||||||
|
updateUiForPipMode()
|
||||||
|
} else {
|
||||||
|
updateUiForNormalMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override fun updateUiForPipMode() {
|
||||||
|
binding!!.callAnswerButtons.visibility = View.INVISIBLE
|
||||||
|
binding!!.incomingCallRelativeLayout.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
public override fun updateUiForNormalMode() {
|
||||||
|
binding!!.callAnswerButtons.visibility = View.VISIBLE
|
||||||
|
binding!!.incomingCallRelativeLayout.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
public override fun suppressFitsSystemWindows() {
|
||||||
|
binding!!.controllerCallNotificationLayout.fitsSystemWindows = false
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "CallNotificationActivity"
|
||||||
|
const val TIMER_START = 1
|
||||||
|
const val TIMER_COUNT = 12
|
||||||
|
const val TIMER_DELAY: Long = 5
|
||||||
|
const val GET_ROOM_RETRY_COUNT: Long = 3
|
||||||
|
}
|
||||||
|
}
|
@ -1,23 +0,0 @@
|
|||||||
/*
|
|
||||||
* Nextcloud Talk application
|
|
||||||
*
|
|
||||||
* @author Mario Danic
|
|
||||||
* Copyright (C) 2017-2019 Mario Danic <mario@lovelyhq.com>
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.nextcloud.talk.events
|
|
||||||
|
|
||||||
class CallNotificationClick
|
|
@ -1,695 +0,0 @@
|
|||||||
/*
|
|
||||||
* Nextcloud Talk application
|
|
||||||
*
|
|
||||||
* @author Andy Scherzinger
|
|
||||||
* @author Mario Danic
|
|
||||||
* Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
|
||||||
* Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.nextcloud.talk.jobs;
|
|
||||||
|
|
||||||
import android.app.Notification;
|
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.graphics.Bitmap;
|
|
||||||
import android.graphics.BitmapFactory;
|
|
||||||
import android.media.AudioAttributes;
|
|
||||||
import android.media.MediaPlayer;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.service.notification.StatusBarNotification;
|
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.util.Base64;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import com.bluelinelabs.logansquare.LoganSquare;
|
|
||||||
import com.nextcloud.talk.R;
|
|
||||||
import com.nextcloud.talk.activities.CallActivity;
|
|
||||||
import com.nextcloud.talk.activities.MainActivity;
|
|
||||||
import com.nextcloud.talk.api.NcApi;
|
|
||||||
import com.nextcloud.talk.application.NextcloudTalkApplication;
|
|
||||||
import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager;
|
|
||||||
import com.nextcloud.talk.data.user.model.User;
|
|
||||||
import com.nextcloud.talk.models.SignatureVerification;
|
|
||||||
import com.nextcloud.talk.models.json.chat.ChatUtils;
|
|
||||||
import com.nextcloud.talk.models.json.conversations.Conversation;
|
|
||||||
import com.nextcloud.talk.models.json.conversations.RoomOverall;
|
|
||||||
import com.nextcloud.talk.models.json.notifications.NotificationOverall;
|
|
||||||
import com.nextcloud.talk.models.json.push.DecryptedPushMessage;
|
|
||||||
import com.nextcloud.talk.models.json.push.NotificationUser;
|
|
||||||
import com.nextcloud.talk.receivers.DirectReplyReceiver;
|
|
||||||
import com.nextcloud.talk.receivers.MarkAsReadReceiver;
|
|
||||||
import com.nextcloud.talk.utils.ApiUtils;
|
|
||||||
import com.nextcloud.talk.utils.DoNotDisturbUtils;
|
|
||||||
import com.nextcloud.talk.utils.NotificationUtils;
|
|
||||||
import com.nextcloud.talk.utils.PushUtils;
|
|
||||||
import com.nextcloud.talk.utils.UserIdUtils;
|
|
||||||
import com.nextcloud.talk.utils.bundle.BundleKeys;
|
|
||||||
import com.nextcloud.talk.utils.preferences.AppPreferences;
|
|
||||||
import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder;
|
|
||||||
|
|
||||||
import org.parceler.Parcels;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.CookieManager;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.security.PrivateKey;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.zip.CRC32;
|
|
||||||
|
|
||||||
import javax.crypto.Cipher;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import javax.inject.Inject;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.annotation.RequiresApi;
|
|
||||||
import androidx.core.app.NotificationCompat;
|
|
||||||
import androidx.core.app.NotificationCompat.MessagingStyle;
|
|
||||||
import androidx.core.app.NotificationManagerCompat;
|
|
||||||
import androidx.core.app.Person;
|
|
||||||
import androidx.core.app.RemoteInput;
|
|
||||||
import androidx.emoji.text.EmojiCompat;
|
|
||||||
import androidx.work.Data;
|
|
||||||
import androidx.work.Worker;
|
|
||||||
import androidx.work.WorkerParameters;
|
|
||||||
import autodagger.AutoInjector;
|
|
||||||
import io.reactivex.Maybe;
|
|
||||||
import io.reactivex.Observer;
|
|
||||||
import io.reactivex.disposables.Disposable;
|
|
||||||
import okhttp3.JavaNetCookieJar;
|
|
||||||
import okhttp3.OkHttpClient;
|
|
||||||
import retrofit2.Retrofit;
|
|
||||||
|
|
||||||
@AutoInjector(NextcloudTalkApplication.class)
|
|
||||||
public class NotificationWorker extends Worker {
|
|
||||||
public static final String TAG = "NotificationWorker";
|
|
||||||
private static final String CHAT = "chat";
|
|
||||||
private static final String ROOM = "room";
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
AppPreferences appPreferences;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
ArbitraryStorageManager arbitraryStorageManager;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
Retrofit retrofit;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
OkHttpClient okHttpClient;
|
|
||||||
|
|
||||||
private NcApi ncApi;
|
|
||||||
|
|
||||||
private DecryptedPushMessage decryptedPushMessage;
|
|
||||||
private Context context;
|
|
||||||
private SignatureVerification signatureVerification;
|
|
||||||
private String conversationType = "one2one";
|
|
||||||
|
|
||||||
private String credentials;
|
|
||||||
private boolean muteCall = false;
|
|
||||||
private boolean importantConversation = false;
|
|
||||||
|
|
||||||
public NotificationWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
|
|
||||||
super(context, workerParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showNotificationForCallWithNoPing(Intent intent) {
|
|
||||||
User user = signatureVerification.getUser();
|
|
||||||
|
|
||||||
importantConversation = arbitraryStorageManager.getStorageSetting(
|
|
||||||
UserIdUtils.INSTANCE.getIdForUser(user),
|
|
||||||
"important_conversation",
|
|
||||||
intent.getExtras().getString(BundleKeys.KEY_ROOM_TOKEN))
|
|
||||||
.map(arbitraryStorage -> {
|
|
||||||
if (arbitraryStorage != null && arbitraryStorage.getValue() != null) {
|
|
||||||
return Boolean.parseBoolean(arbitraryStorage.getValue());
|
|
||||||
} else {
|
|
||||||
return importantConversation;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.switchIfEmpty(Maybe.just(importantConversation))
|
|
||||||
.blockingGet();
|
|
||||||
|
|
||||||
Log.e(TAG, "showNotificationForCallWithNoPing: importantConversation: " + importantConversation);
|
|
||||||
|
|
||||||
int apiVersion = ApiUtils.getConversationApiVersion(user, new int[] {ApiUtils.APIv4, 1});
|
|
||||||
|
|
||||||
ncApi.getRoom(credentials, ApiUtils.getUrlForRoom(apiVersion, user.getBaseUrl(),
|
|
||||||
intent.getExtras().getString(BundleKeys.KEY_ROOM_TOKEN)))
|
|
||||||
.blockingSubscribe(new Observer<RoomOverall>() {
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(Disposable d) {
|
|
||||||
// unused atm
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(RoomOverall roomOverall) {
|
|
||||||
Conversation conversation = roomOverall.getOcs().getData();
|
|
||||||
|
|
||||||
intent.putExtra(BundleKeys.KEY_ROOM, Parcels.wrap(conversation));
|
|
||||||
if (conversation.getType().equals(Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) ||
|
|
||||||
(!TextUtils.isEmpty(conversation.getObjectType()) && "share:password".equals
|
|
||||||
(conversation.getObjectType()))) {
|
|
||||||
context.startActivity(intent);
|
|
||||||
} else {
|
|
||||||
if (conversation.getType().equals(Conversation.ConversationType.ROOM_GROUP_CALL)) {
|
|
||||||
conversationType = "group";
|
|
||||||
} else {
|
|
||||||
conversationType = "public";
|
|
||||||
}
|
|
||||||
if (decryptedPushMessage.getNotificationId() != Long.MIN_VALUE) {
|
|
||||||
showNotificationWithObjectData(intent);
|
|
||||||
} else {
|
|
||||||
showNotification(intent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
muteCall = conversation.getNotificationCalls() != 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable e) {
|
|
||||||
// unused atm
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
// unused atm
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showNotificationWithObjectData(Intent intent) {
|
|
||||||
User user = signatureVerification.getUser();
|
|
||||||
ncApi.getNotification(credentials, ApiUtils.getUrlForNotificationWithId(user.getBaseUrl(),
|
|
||||||
Long.toString(decryptedPushMessage.getNotificationId())))
|
|
||||||
.blockingSubscribe(new Observer<NotificationOverall>() {
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(Disposable d) {
|
|
||||||
// unused atm
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(NotificationOverall notificationOverall) {
|
|
||||||
com.nextcloud.talk.models.json.notifications.Notification notification =
|
|
||||||
notificationOverall.getOcs().getNotification();
|
|
||||||
|
|
||||||
if (notification.getMessageRichParameters() != null &&
|
|
||||||
notification.getMessageRichParameters().size() > 0) {
|
|
||||||
decryptedPushMessage.setText(ChatUtils.Companion.getParsedMessage(
|
|
||||||
notification.getMessageRich(),
|
|
||||||
notification.getMessageRichParameters()));
|
|
||||||
} else {
|
|
||||||
decryptedPushMessage.setText(notification.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
HashMap<String, HashMap<String, String>> subjectRichParameters = notification
|
|
||||||
.getSubjectRichParameters();
|
|
||||||
|
|
||||||
decryptedPushMessage.setTimestamp(notification.getDatetime().getMillis());
|
|
||||||
|
|
||||||
if (subjectRichParameters != null && subjectRichParameters.size() > 0) {
|
|
||||||
HashMap<String, String> callHashMap = subjectRichParameters.get("call");
|
|
||||||
HashMap<String, String> userHashMap = subjectRichParameters.get("user");
|
|
||||||
HashMap<String, String> guestHashMap = subjectRichParameters.get("guest");
|
|
||||||
|
|
||||||
if (callHashMap != null && callHashMap.size() > 0 && callHashMap.containsKey("name")) {
|
|
||||||
if (subjectRichParameters.containsKey("reaction")) {
|
|
||||||
decryptedPushMessage.setSubject("");
|
|
||||||
decryptedPushMessage.setText(notification.getSubject());
|
|
||||||
} else if (Objects.equals(notification.getObjectType(), "chat")) {
|
|
||||||
decryptedPushMessage.setSubject(Objects.requireNonNull(callHashMap.get("name")));
|
|
||||||
} else {
|
|
||||||
decryptedPushMessage.setSubject(Objects.requireNonNull(notification.getSubject()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (callHashMap.containsKey("call-type")) {
|
|
||||||
conversationType = callHashMap.get("call-type");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NotificationUser notificationUser = new NotificationUser();
|
|
||||||
if (userHashMap != null && !userHashMap.isEmpty()) {
|
|
||||||
notificationUser.setId(userHashMap.get("id"));
|
|
||||||
notificationUser.setType(userHashMap.get("type"));
|
|
||||||
notificationUser.setName(userHashMap.get("name"));
|
|
||||||
decryptedPushMessage.setNotificationUser(notificationUser);
|
|
||||||
} else if (guestHashMap != null && !guestHashMap.isEmpty()) {
|
|
||||||
notificationUser.setId(guestHashMap.get("id"));
|
|
||||||
notificationUser.setType(guestHashMap.get("type"));
|
|
||||||
notificationUser.setName(guestHashMap.get("name"));
|
|
||||||
decryptedPushMessage.setNotificationUser(notificationUser);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptedPushMessage.setObjectId(notification.getObjectId());
|
|
||||||
|
|
||||||
showNotification(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable e) {
|
|
||||||
// unused atm
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
// unused atm
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showNotification(Intent intent) {
|
|
||||||
int smallIcon;
|
|
||||||
Bitmap largeIcon;
|
|
||||||
String category;
|
|
||||||
int priority = Notification.PRIORITY_HIGH;
|
|
||||||
|
|
||||||
smallIcon = R.drawable.ic_logo;
|
|
||||||
|
|
||||||
if (CHAT.equals(decryptedPushMessage.getType()) || ROOM.equals(decryptedPushMessage.getType())) {
|
|
||||||
category = Notification.CATEGORY_MESSAGE;
|
|
||||||
} else {
|
|
||||||
category = Notification.CATEGORY_CALL;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (conversationType) {
|
|
||||||
case "one2one":
|
|
||||||
decryptedPushMessage.setSubject("");
|
|
||||||
case "group":
|
|
||||||
largeIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_people_group_black_24px);
|
|
||||||
break;
|
|
||||||
case "public":
|
|
||||||
largeIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_link_black_24px);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// assuming one2one
|
|
||||||
if (CHAT.equals(decryptedPushMessage.getType()) || ROOM.equals(decryptedPushMessage.getType())) {
|
|
||||||
largeIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_comment);
|
|
||||||
} else {
|
|
||||||
largeIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_call_black_24dp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use unique request code to make sure that a new PendingIntent gets created for each notification
|
|
||||||
// See https://github.com/nextcloud/talk-android/issues/2111
|
|
||||||
int requestCode = (int) System.currentTimeMillis();
|
|
||||||
int intentFlag;
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
intentFlag = PendingIntent.FLAG_MUTABLE;
|
|
||||||
} else {
|
|
||||||
intentFlag = 0;
|
|
||||||
}
|
|
||||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, requestCode, intent, intentFlag);
|
|
||||||
|
|
||||||
Uri uri = Uri.parse(signatureVerification.getUser().getBaseUrl());
|
|
||||||
String baseUrl = uri.getHost();
|
|
||||||
|
|
||||||
NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(context, "1")
|
|
||||||
.setLargeIcon(largeIcon)
|
|
||||||
.setSmallIcon(smallIcon)
|
|
||||||
.setCategory(category)
|
|
||||||
.setPriority(priority)
|
|
||||||
.setSubText(baseUrl)
|
|
||||||
.setWhen(decryptedPushMessage.getTimestamp())
|
|
||||||
.setShowWhen(true)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.setAutoCancel(true);
|
|
||||||
|
|
||||||
if (!TextUtils.isEmpty(decryptedPushMessage.getSubject())) {
|
|
||||||
notificationBuilder.setContentTitle(EmojiCompat.get().process(decryptedPushMessage.getSubject()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!TextUtils.isEmpty(decryptedPushMessage.getText())) {
|
|
||||||
notificationBuilder.setContentText(EmojiCompat.get().process(decryptedPushMessage.getText()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= 23) {
|
|
||||||
// This method should exist since API 21, but some phones don't have it
|
|
||||||
// So as a safeguard, we don't use it until 23
|
|
||||||
|
|
||||||
notificationBuilder.setColor(context.getResources().getColor(R.color.colorPrimary));
|
|
||||||
}
|
|
||||||
|
|
||||||
Bundle notificationInfo = new Bundle();
|
|
||||||
notificationInfo.putLong(BundleKeys.KEY_INTERNAL_USER_ID,
|
|
||||||
signatureVerification.getUser().getId());
|
|
||||||
// could be an ID or a TOKEN
|
|
||||||
notificationInfo.putString(BundleKeys.KEY_ROOM_TOKEN,
|
|
||||||
decryptedPushMessage.getId());
|
|
||||||
notificationInfo.putLong(BundleKeys.KEY_NOTIFICATION_ID,
|
|
||||||
decryptedPushMessage.getNotificationId());
|
|
||||||
notificationBuilder.setExtras(notificationInfo);
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
if (CHAT.equals(decryptedPushMessage.getType()) || ROOM.equals(decryptedPushMessage.getType())) {
|
|
||||||
notificationBuilder.setChannelId(NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// red color for the lights
|
|
||||||
notificationBuilder.setLights(0xFFFF0000, 200, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationBuilder.setContentIntent(pendingIntent);
|
|
||||||
|
|
||||||
String groupName = signatureVerification.getUser().getId() + "@" + decryptedPushMessage.getId();
|
|
||||||
notificationBuilder.setGroup(Long.toString(calculateCRC32(groupName)));
|
|
||||||
|
|
||||||
StatusBarNotification activeStatusBarNotification =
|
|
||||||
NotificationUtils.INSTANCE.findNotificationForRoom(context,
|
|
||||||
signatureVerification.getUser(),
|
|
||||||
decryptedPushMessage.getId());
|
|
||||||
|
|
||||||
// NOTE - systemNotificationId is an internal ID used on the device only.
|
|
||||||
// It is NOT the same as the notification ID used in communication with the server.
|
|
||||||
int systemNotificationId;
|
|
||||||
if (activeStatusBarNotification != null) {
|
|
||||||
systemNotificationId = activeStatusBarNotification.getId();
|
|
||||||
} else {
|
|
||||||
systemNotificationId = (int) calculateCRC32(String.valueOf(System.currentTimeMillis()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
|
||||||
CHAT.equals(decryptedPushMessage.getType()) &&
|
|
||||||
decryptedPushMessage.getNotificationUser() != null) {
|
|
||||||
prepareChatNotification(notificationBuilder, activeStatusBarNotification, systemNotificationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendNotification(systemNotificationId, notificationBuilder.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
private long calculateCRC32(String s) {
|
|
||||||
CRC32 crc32 = new CRC32();
|
|
||||||
crc32.update(s.getBytes());
|
|
||||||
return crc32.getValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.N)
|
|
||||||
private void prepareChatNotification(NotificationCompat.Builder notificationBuilder,
|
|
||||||
StatusBarNotification activeStatusBarNotification,
|
|
||||||
int systemNotificationId) {
|
|
||||||
|
|
||||||
final NotificationUser notificationUser = decryptedPushMessage.getNotificationUser();
|
|
||||||
final String userType = notificationUser.getType();
|
|
||||||
|
|
||||||
MessagingStyle style = null;
|
|
||||||
if (activeStatusBarNotification != null) {
|
|
||||||
style = MessagingStyle.extractMessagingStyleFromNotification(activeStatusBarNotification.getNotification());
|
|
||||||
}
|
|
||||||
|
|
||||||
Person.Builder person =
|
|
||||||
new Person.Builder()
|
|
||||||
.setKey(signatureVerification.getUser().getId() + "@" + notificationUser.getId())
|
|
||||||
.setName(EmojiCompat.get().process(notificationUser.getName()))
|
|
||||||
.setBot("bot".equals(userType));
|
|
||||||
|
|
||||||
notificationBuilder.setOnlyAlertOnce(true);
|
|
||||||
addReplyAction(notificationBuilder, systemNotificationId);
|
|
||||||
addMarkAsReadAction(notificationBuilder, systemNotificationId);
|
|
||||||
|
|
||||||
if ("user".equals(userType) || "guest".equals(userType)) {
|
|
||||||
String baseUrl = signatureVerification.getUser().getBaseUrl();
|
|
||||||
String avatarUrl = "user".equals(userType) ?
|
|
||||||
ApiUtils.getUrlForAvatar(baseUrl, notificationUser.getId(), false) :
|
|
||||||
ApiUtils.getUrlForGuestAvatar(baseUrl, notificationUser.getName(), false);
|
|
||||||
person.setIcon(NotificationUtils.INSTANCE.loadAvatarSync(avatarUrl));
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationBuilder.setStyle(getStyle(person.build(), style));
|
|
||||||
}
|
|
||||||
|
|
||||||
private PendingIntent buildIntentForAction(Class<?> cls, int systemNotificationId, int messageId) {
|
|
||||||
Intent actualIntent = new Intent(context, cls);
|
|
||||||
|
|
||||||
// NOTE - systemNotificationId is an internal ID used on the device only.
|
|
||||||
// It is NOT the same as the notification ID used in communication with the server.
|
|
||||||
actualIntent.putExtra(BundleKeys.KEY_SYSTEM_NOTIFICATION_ID, systemNotificationId);
|
|
||||||
actualIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID,
|
|
||||||
Objects.requireNonNull(signatureVerification.getUser()).getId());
|
|
||||||
actualIntent.putExtra(BundleKeys.KEY_ROOM_TOKEN, decryptedPushMessage.getId());
|
|
||||||
actualIntent.putExtra(BundleKeys.KEY_MESSAGE_ID, messageId);
|
|
||||||
|
|
||||||
int intentFlag;
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
intentFlag = PendingIntent.FLAG_MUTABLE|PendingIntent.FLAG_UPDATE_CURRENT;
|
|
||||||
} else {
|
|
||||||
intentFlag = PendingIntent.FLAG_UPDATE_CURRENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
return PendingIntent.getBroadcast(context, systemNotificationId, actualIntent, intentFlag);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addMarkAsReadAction(NotificationCompat.Builder notificationBuilder, int systemNotificationId) {
|
|
||||||
if (decryptedPushMessage.getObjectId() != null) {
|
|
||||||
int messageId = 0;
|
|
||||||
try {
|
|
||||||
messageId = parseMessageId(decryptedPushMessage.getObjectId());
|
|
||||||
} catch (NumberFormatException nfe) {
|
|
||||||
Log.e(TAG, "Failed to parse messageId from objectId, skip adding mark-as-read action.", nfe);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a PendingIntent for the mark as read action
|
|
||||||
PendingIntent pendingIntent = buildIntentForAction(MarkAsReadReceiver.class,
|
|
||||||
systemNotificationId,
|
|
||||||
messageId);
|
|
||||||
|
|
||||||
NotificationCompat.Action action =
|
|
||||||
new NotificationCompat.Action.Builder(R.drawable.ic_eye,
|
|
||||||
context.getResources().getString(R.string.nc_mark_as_read),
|
|
||||||
pendingIntent)
|
|
||||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
|
||||||
.setShowsUserInterface(false)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
notificationBuilder.addAction(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.N)
|
|
||||||
private void addReplyAction(NotificationCompat.Builder notificationBuilder, int systemNotificationId) {
|
|
||||||
String replyLabel = context.getResources().getString(R.string.nc_reply);
|
|
||||||
|
|
||||||
RemoteInput remoteInput = new RemoteInput.Builder(NotificationUtils.KEY_DIRECT_REPLY)
|
|
||||||
.setLabel(replyLabel)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Build a PendingIntent for the reply action
|
|
||||||
PendingIntent replyPendingIntent = buildIntentForAction(DirectReplyReceiver.class, systemNotificationId, 0);
|
|
||||||
|
|
||||||
NotificationCompat.Action replyAction =
|
|
||||||
new NotificationCompat.Action.Builder(R.drawable.ic_reply, replyLabel, replyPendingIntent)
|
|
||||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
|
||||||
.setShowsUserInterface(false)
|
|
||||||
// Allows system to generate replies by context of conversation.
|
|
||||||
// https://developer.android.com/reference/androidx/core/app/NotificationCompat.Action.Builder#setAllowGeneratedReplies(boolean)
|
|
||||||
// Good question is - do we really want it?
|
|
||||||
.setAllowGeneratedReplies(true)
|
|
||||||
.addRemoteInput(remoteInput)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
notificationBuilder.addAction(replyAction);
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.N)
|
|
||||||
private MessagingStyle getStyle(Person person, @Nullable MessagingStyle style) {
|
|
||||||
MessagingStyle newStyle = new MessagingStyle(person);
|
|
||||||
|
|
||||||
newStyle.setConversationTitle(decryptedPushMessage.getSubject());
|
|
||||||
newStyle.setGroupConversation(!"one2one".equals(conversationType));
|
|
||||||
|
|
||||||
if (style != null) {
|
|
||||||
style.getMessages().forEach(message -> newStyle.addMessage(
|
|
||||||
new MessagingStyle.Message(message.getText(),
|
|
||||||
message.getTimestamp(),
|
|
||||||
message.getPerson())));
|
|
||||||
}
|
|
||||||
|
|
||||||
newStyle.addMessage(decryptedPushMessage.getText(), decryptedPushMessage.getTimestamp(), person);
|
|
||||||
return newStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int parseMessageId(@NonNull String objectId) {
|
|
||||||
String[] objectIdParts = objectId.split("/");
|
|
||||||
if (objectIdParts.length < 2) {
|
|
||||||
throw new NumberFormatException("Invalid objectId, doesn't contain at least one '/'");
|
|
||||||
} else {
|
|
||||||
return Integer.parseInt(objectIdParts[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void sendNotification(int notificationId, Notification notification) {
|
|
||||||
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
|
|
||||||
notificationManager.notify(notificationId, notification);
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
// On devices with Android 8.0 (Oreo) or later, notification sound will be handled by the system
|
|
||||||
// if notifications have not been disabled by the user.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Notification.CATEGORY_CALL.equals(notification.category) || !muteCall) {
|
|
||||||
Uri soundUri = NotificationUtils.INSTANCE.getMessageRingtoneUri(context, appPreferences);
|
|
||||||
if (soundUri != null && !ApplicationWideCurrentRoomHolder.getInstance().isInCall() &&
|
|
||||||
(DoNotDisturbUtils.INSTANCE.shouldPlaySound() || importantConversation)) {
|
|
||||||
AudioAttributes.Builder audioAttributesBuilder = new AudioAttributes.Builder().setContentType
|
|
||||||
(AudioAttributes.CONTENT_TYPE_SONIFICATION);
|
|
||||||
|
|
||||||
if (CHAT.equals(decryptedPushMessage.getType()) || ROOM.equals(decryptedPushMessage.getType())) {
|
|
||||||
audioAttributesBuilder.setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT);
|
|
||||||
} else {
|
|
||||||
audioAttributesBuilder.setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
MediaPlayer mediaPlayer = new MediaPlayer();
|
|
||||||
try {
|
|
||||||
mediaPlayer.setDataSource(context, soundUri);
|
|
||||||
mediaPlayer.setAudioAttributes(audioAttributesBuilder.build());
|
|
||||||
|
|
||||||
mediaPlayer.setOnPreparedListener(mp -> mediaPlayer.start());
|
|
||||||
mediaPlayer.setOnCompletionListener(MediaPlayer::release);
|
|
||||||
|
|
||||||
mediaPlayer.prepareAsync();
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.e(TAG, "Failed to set data source");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
@Override
|
|
||||||
public Result doWork() {
|
|
||||||
NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this);
|
|
||||||
|
|
||||||
context = getApplicationContext();
|
|
||||||
Data data = getInputData();
|
|
||||||
String subject = data.getString(BundleKeys.KEY_NOTIFICATION_SUBJECT);
|
|
||||||
String signature = data.getString(BundleKeys.KEY_NOTIFICATION_SIGNATURE);
|
|
||||||
|
|
||||||
try {
|
|
||||||
byte[] base64DecodedSubject = Base64.decode(subject, Base64.DEFAULT);
|
|
||||||
byte[] base64DecodedSignature = Base64.decode(signature, Base64.DEFAULT);
|
|
||||||
PushUtils pushUtils = new PushUtils();
|
|
||||||
PrivateKey privateKey = (PrivateKey) pushUtils.readKeyFromFile(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
signatureVerification = pushUtils.verifySignature(base64DecodedSignature,
|
|
||||||
base64DecodedSubject);
|
|
||||||
|
|
||||||
if (signatureVerification.getSignatureValid()) {
|
|
||||||
Cipher cipher = Cipher.getInstance("RSA/None/PKCS1Padding");
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, privateKey);
|
|
||||||
byte[] decryptedSubject = cipher.doFinal(base64DecodedSubject);
|
|
||||||
decryptedPushMessage = LoganSquare.parse(new String(decryptedSubject),
|
|
||||||
DecryptedPushMessage.class);
|
|
||||||
|
|
||||||
decryptedPushMessage.setTimestamp(System.currentTimeMillis());
|
|
||||||
if (decryptedPushMessage.getDelete()) {
|
|
||||||
NotificationUtils.INSTANCE.cancelExistingNotificationWithId(
|
|
||||||
context,
|
|
||||||
signatureVerification.getUser(),
|
|
||||||
decryptedPushMessage.getNotificationId());
|
|
||||||
} else if (decryptedPushMessage.getDeleteAll()) {
|
|
||||||
NotificationUtils.INSTANCE.cancelAllNotificationsForAccount(
|
|
||||||
context,
|
|
||||||
signatureVerification.getUser());
|
|
||||||
} else if (decryptedPushMessage.getDeleteMultiple()) {
|
|
||||||
for (long notificationId : decryptedPushMessage.getNotificationIds()) {
|
|
||||||
NotificationUtils.INSTANCE.cancelExistingNotificationWithId(
|
|
||||||
context,
|
|
||||||
signatureVerification.getUser(),
|
|
||||||
notificationId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
credentials = ApiUtils.getCredentials(signatureVerification.getUser().getUsername(),
|
|
||||||
signatureVerification.getUser().getToken());
|
|
||||||
|
|
||||||
ncApi = retrofit.newBuilder().client(okHttpClient.newBuilder().cookieJar(new
|
|
||||||
JavaNetCookieJar(new CookieManager())).build()).build().create(NcApi.class);
|
|
||||||
|
|
||||||
boolean shouldShowNotification = "spreed".equals(decryptedPushMessage.getApp());
|
|
||||||
|
|
||||||
if (shouldShowNotification) {
|
|
||||||
Intent intent;
|
|
||||||
Bundle bundle = new Bundle();
|
|
||||||
|
|
||||||
|
|
||||||
boolean startACall = "call".equals(decryptedPushMessage.getType());
|
|
||||||
if (startACall) {
|
|
||||||
intent = new Intent(context, CallActivity.class);
|
|
||||||
} else {
|
|
||||||
intent = new Intent(context, MainActivity.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
||||||
|
|
||||||
bundle.putString(BundleKeys.KEY_ROOM_TOKEN, decryptedPushMessage.getId());
|
|
||||||
|
|
||||||
bundle.putParcelable(BundleKeys.KEY_USER_ENTITY,
|
|
||||||
signatureVerification.getUser());
|
|
||||||
|
|
||||||
bundle.putBoolean(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL,
|
|
||||||
startACall);
|
|
||||||
|
|
||||||
intent.putExtras(bundle);
|
|
||||||
|
|
||||||
Log.e(TAG, "Notification: " + decryptedPushMessage.getType());
|
|
||||||
|
|
||||||
switch (decryptedPushMessage.getType()) {
|
|
||||||
case "call":
|
|
||||||
if (bundle.containsKey(BundleKeys.KEY_ROOM_TOKEN)) {
|
|
||||||
showNotificationForCallWithNoPing(intent);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "room":
|
|
||||||
if (bundle.containsKey(BundleKeys.KEY_ROOM_TOKEN)) {
|
|
||||||
showNotificationWithObjectData(intent);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "chat":
|
|
||||||
if (decryptedPushMessage.getNotificationId() != Long.MIN_VALUE) {
|
|
||||||
showNotificationWithObjectData(intent);
|
|
||||||
} else {
|
|
||||||
showNotification(intent);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (NoSuchAlgorithmException e1) {
|
|
||||||
Log.d(TAG, "No proper algorithm to decrypt the message " + e1.getLocalizedMessage());
|
|
||||||
} catch (NoSuchPaddingException e1) {
|
|
||||||
Log.d(TAG, "No proper padding to decrypt the message " + e1.getLocalizedMessage());
|
|
||||||
} catch (InvalidKeyException e1) {
|
|
||||||
Log.d(TAG, "Invalid private key " + e1.getLocalizedMessage());
|
|
||||||
}
|
|
||||||
} catch (Exception exception) {
|
|
||||||
Log.d(TAG, "Something went very wrong " + exception.getLocalizedMessage());
|
|
||||||
}
|
|
||||||
return Result.success();
|
|
||||||
}
|
|
||||||
}
|
|
849
app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt
Normal file
849
app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt
Normal file
@ -0,0 +1,849 @@
|
|||||||
|
/*
|
||||||
|
* Nextcloud Talk application
|
||||||
|
*
|
||||||
|
* @author Andy Scherzinger
|
||||||
|
* @author Mario Danic
|
||||||
|
* @author Marcel Hibbe
|
||||||
|
* Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||||
|
* Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||||
|
* Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.nextcloud.talk.jobs
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Context.NOTIFICATION_SERVICE
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.service.notification.StatusBarNotification
|
||||||
|
import android.text.TextUtils
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.Person
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.emoji.text.EmojiCompat
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import autodagger.AutoInjector
|
||||||
|
import com.bluelinelabs.logansquare.LoganSquare
|
||||||
|
import com.nextcloud.talk.R
|
||||||
|
import com.nextcloud.talk.activities.CallNotificationActivity
|
||||||
|
import com.nextcloud.talk.activities.MainActivity
|
||||||
|
import com.nextcloud.talk.api.NcApi
|
||||||
|
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||||
|
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||||
|
import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
|
||||||
|
import com.nextcloud.talk.models.SignatureVerification
|
||||||
|
import com.nextcloud.talk.models.json.chat.ChatUtils.Companion.getParsedMessage
|
||||||
|
import com.nextcloud.talk.models.json.conversations.RoomOverall
|
||||||
|
import com.nextcloud.talk.models.json.notifications.NotificationOverall
|
||||||
|
import com.nextcloud.talk.models.json.participants.Participant
|
||||||
|
import com.nextcloud.talk.models.json.participants.ParticipantsOverall
|
||||||
|
import com.nextcloud.talk.models.json.push.DecryptedPushMessage
|
||||||
|
import com.nextcloud.talk.models.json.push.NotificationUser
|
||||||
|
import com.nextcloud.talk.receivers.DirectReplyReceiver
|
||||||
|
import com.nextcloud.talk.receivers.MarkAsReadReceiver
|
||||||
|
import com.nextcloud.talk.utils.ApiUtils
|
||||||
|
import com.nextcloud.talk.utils.DoNotDisturbUtils.shouldPlaySound
|
||||||
|
import com.nextcloud.talk.utils.NotificationUtils
|
||||||
|
import com.nextcloud.talk.utils.NotificationUtils.cancelAllNotificationsForAccount
|
||||||
|
import com.nextcloud.talk.utils.NotificationUtils.cancelNotification
|
||||||
|
import com.nextcloud.talk.utils.NotificationUtils.findNotificationForRoom
|
||||||
|
import com.nextcloud.talk.utils.NotificationUtils.getCallRingtoneUri
|
||||||
|
import com.nextcloud.talk.utils.NotificationUtils.getMessageRingtoneUri
|
||||||
|
import com.nextcloud.talk.utils.NotificationUtils.loadAvatarSync
|
||||||
|
import com.nextcloud.talk.utils.PushUtils
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FROM_NOTIFICATION_START_CALL
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_MESSAGE_ID
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_NOTIFICATION_ID
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID
|
||||||
|
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY
|
||||||
|
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||||
|
import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder
|
||||||
|
import io.reactivex.Observable
|
||||||
|
import io.reactivex.Observer
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.disposables.Disposable
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import okhttp3.JavaNetCookieJar
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.CookieManager
|
||||||
|
import java.security.InvalidKeyException
|
||||||
|
import java.security.NoSuchAlgorithmException
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.function.Consumer
|
||||||
|
import java.util.zip.CRC32
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.NoSuchPaddingException
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AutoInjector(NextcloudTalkApplication::class)
|
||||||
|
class NotificationWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var appPreferences: AppPreferences
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
@Inject
|
||||||
|
var arbitraryStorageManager: ArbitraryStorageManager? = null
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
@Inject
|
||||||
|
var retrofit: Retrofit? = null
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
@Inject
|
||||||
|
var okHttpClient: OkHttpClient? = null
|
||||||
|
private lateinit var credentials: String
|
||||||
|
private lateinit var ncApi: NcApi
|
||||||
|
private lateinit var pushMessage: DecryptedPushMessage
|
||||||
|
private lateinit var signatureVerification: SignatureVerification
|
||||||
|
private var context: Context? = null
|
||||||
|
private var conversationType: String? = "one2one"
|
||||||
|
private var muteCall = false
|
||||||
|
private var importantConversation = false
|
||||||
|
private lateinit var notificationManager: NotificationManagerCompat
|
||||||
|
|
||||||
|
override fun doWork(): Result {
|
||||||
|
sharedApplication!!.componentApplication.inject(this)
|
||||||
|
context = applicationContext
|
||||||
|
|
||||||
|
initDecryptedData(inputData)
|
||||||
|
initNcApiAndCredentials()
|
||||||
|
|
||||||
|
notificationManager = NotificationManagerCompat.from(context!!)
|
||||||
|
|
||||||
|
pushMessage.timestamp = System.currentTimeMillis()
|
||||||
|
|
||||||
|
Log.d(TAG, pushMessage.toString())
|
||||||
|
Log.d(TAG, "pushMessage.id (=KEY_ROOM_TOKEN): " + pushMessage.id)
|
||||||
|
Log.d(TAG, "pushMessage.notificationId: " + pushMessage.notificationId)
|
||||||
|
Log.d(TAG, "pushMessage.notificationIds: " + pushMessage.notificationIds)
|
||||||
|
Log.d(TAG, "pushMessage.timestamp: " + pushMessage.timestamp)
|
||||||
|
|
||||||
|
if (pushMessage.delete) {
|
||||||
|
cancelNotification(context, signatureVerification.user!!, pushMessage.notificationId)
|
||||||
|
} else if (pushMessage.deleteAll) {
|
||||||
|
cancelAllNotificationsForAccount(context, signatureVerification.user!!)
|
||||||
|
} else if (pushMessage.deleteMultiple) {
|
||||||
|
for (notificationId in pushMessage.notificationIds!!) {
|
||||||
|
cancelNotification(context, signatureVerification.user!!, notificationId)
|
||||||
|
}
|
||||||
|
} else if (isSpreedNotification()) {
|
||||||
|
Log.d(TAG, "pushMessage.type: " + pushMessage.type)
|
||||||
|
when (pushMessage.type) {
|
||||||
|
"chat" -> handleChatNotification()
|
||||||
|
"room" -> handleRoomNotification()
|
||||||
|
"call" -> handleCallNotification()
|
||||||
|
else -> Log.e(TAG, "unknown pushMessage.type")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "a pushMessage that is not for spreed was received.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleChatNotification() {
|
||||||
|
val chatIntent = Intent(context, MainActivity::class.java)
|
||||||
|
chatIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
val chatBundle = Bundle()
|
||||||
|
chatBundle.putString(KEY_ROOM_TOKEN, pushMessage.id)
|
||||||
|
chatBundle.putParcelable(KEY_USER_ENTITY, signatureVerification.user)
|
||||||
|
chatBundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, false)
|
||||||
|
chatIntent.putExtras(chatBundle)
|
||||||
|
if (pushMessage.notificationId != Long.MIN_VALUE) {
|
||||||
|
showNotificationWithObjectData(chatIntent)
|
||||||
|
} else {
|
||||||
|
showNotification(chatIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handle messages with type 'room', e.g. "xxx invited you to a group conversation"
|
||||||
|
*/
|
||||||
|
private fun handleRoomNotification() {
|
||||||
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
val bundle = Bundle()
|
||||||
|
bundle.putString(KEY_ROOM_TOKEN, pushMessage.id)
|
||||||
|
bundle.putParcelable(KEY_USER_ENTITY, signatureVerification.user)
|
||||||
|
bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, false)
|
||||||
|
intent.putExtras(bundle)
|
||||||
|
if (bundle.containsKey(KEY_ROOM_TOKEN)) {
|
||||||
|
showNotificationWithObjectData(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCallNotification() {
|
||||||
|
val fullScreenIntent = Intent(context, CallNotificationActivity::class.java)
|
||||||
|
val bundle = Bundle()
|
||||||
|
bundle.putString(KEY_ROOM_TOKEN, pushMessage.id)
|
||||||
|
bundle.putParcelable(KEY_USER_ENTITY, signatureVerification.user)
|
||||||
|
bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, true)
|
||||||
|
fullScreenIntent.putExtras(bundle)
|
||||||
|
fullScreenIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
|
val requestCode = System.currentTimeMillis().toInt()
|
||||||
|
|
||||||
|
val fullScreenPendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
requestCode,
|
||||||
|
fullScreenIntent,
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val soundUri = getCallRingtoneUri(applicationContext, appPreferences)
|
||||||
|
val notificationChannelId = NotificationUtils
|
||||||
|
.NotificationChannels.NOTIFICATION_CHANNEL_CALLS_V4.name
|
||||||
|
val uri = Uri.parse(signatureVerification.user!!.baseUrl)
|
||||||
|
val baseUrl = uri.host
|
||||||
|
|
||||||
|
val notification =
|
||||||
|
NotificationCompat.Builder(applicationContext, notificationChannelId)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
|
.setSmallIcon(R.drawable.ic_call_black_24dp)
|
||||||
|
.setSubText(baseUrl)
|
||||||
|
.setShowWhen(true)
|
||||||
|
.setWhen(pushMessage.timestamp)
|
||||||
|
.setContentTitle(EmojiCompat.get().process(pushMessage.subject))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setContentIntent(fullScreenPendingIntent)
|
||||||
|
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||||
|
.setSound(soundUri)
|
||||||
|
.build()
|
||||||
|
notification.flags = notification.flags or Notification.FLAG_INSISTENT
|
||||||
|
|
||||||
|
sendNotification(pushMessage.timestamp.toInt(), notification)
|
||||||
|
|
||||||
|
checkIfCallIsActive(signatureVerification, pushMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initNcApiAndCredentials() {
|
||||||
|
credentials = ApiUtils.getCredentials(
|
||||||
|
signatureVerification.user!!.username,
|
||||||
|
signatureVerification.user!!.token
|
||||||
|
)
|
||||||
|
ncApi = retrofit!!.newBuilder().client(
|
||||||
|
okHttpClient!!.newBuilder().cookieJar(
|
||||||
|
JavaNetCookieJar(
|
||||||
|
CookieManager()
|
||||||
|
)
|
||||||
|
).build()
|
||||||
|
).build().create(
|
||||||
|
NcApi::class.java
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("TooGenericExceptionCaught", "NestedBlockDepth", "ComplexMethod", "LongMethod")
|
||||||
|
private fun initDecryptedData(inputData: Data) {
|
||||||
|
val subject = inputData.getString(BundleKeys.KEY_NOTIFICATION_SUBJECT)
|
||||||
|
val signature = inputData.getString(BundleKeys.KEY_NOTIFICATION_SIGNATURE)
|
||||||
|
try {
|
||||||
|
val base64DecodedSubject = Base64.decode(subject, Base64.DEFAULT)
|
||||||
|
val base64DecodedSignature = Base64.decode(signature, Base64.DEFAULT)
|
||||||
|
val pushUtils = PushUtils()
|
||||||
|
val privateKey = pushUtils.readKeyFromFile(false) as PrivateKey
|
||||||
|
try {
|
||||||
|
signatureVerification = pushUtils.verifySignature(
|
||||||
|
base64DecodedSignature,
|
||||||
|
base64DecodedSubject
|
||||||
|
)
|
||||||
|
if (signatureVerification.signatureValid) {
|
||||||
|
val cipher = Cipher.getInstance("RSA/None/PKCS1Padding")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, privateKey)
|
||||||
|
val decryptedSubject = cipher.doFinal(base64DecodedSubject)
|
||||||
|
|
||||||
|
pushMessage = LoganSquare.parse(
|
||||||
|
String(decryptedSubject),
|
||||||
|
DecryptedPushMessage::class.java
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: NoSuchAlgorithmException) {
|
||||||
|
Log.e(TAG, "No proper algorithm to decrypt the message ", e)
|
||||||
|
} catch (e: NoSuchPaddingException) {
|
||||||
|
Log.e(TAG, "No proper padding to decrypt the message ", e)
|
||||||
|
} catch (e: InvalidKeyException) {
|
||||||
|
Log.e(TAG, "Invalid private key ", e)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error occurred while initializing decoded data ", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSpreedNotification() = SPREED_APP == pushMessage.app
|
||||||
|
|
||||||
|
private fun showNotificationWithObjectData(intent: Intent) {
|
||||||
|
val user = signatureVerification.user
|
||||||
|
|
||||||
|
// see https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md
|
||||||
|
ncApi.getNotification(
|
||||||
|
credentials,
|
||||||
|
ApiUtils.getUrlForNotificationWithId(
|
||||||
|
user!!.baseUrl,
|
||||||
|
(pushMessage.notificationId!!).toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.blockingSubscribe(object : Observer<NotificationOverall> {
|
||||||
|
override fun onSubscribe(d: Disposable) {
|
||||||
|
// unused atm
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNext(notificationOverall: NotificationOverall) {
|
||||||
|
val ncNotification = notificationOverall.ocs!!.notification
|
||||||
|
|
||||||
|
if (ncNotification!!.messageRichParameters != null &&
|
||||||
|
ncNotification.messageRichParameters!!.size > 0
|
||||||
|
) {
|
||||||
|
pushMessage.text = getParsedMessage(
|
||||||
|
ncNotification.messageRich,
|
||||||
|
ncNotification.messageRichParameters
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
pushMessage.text = ncNotification.message
|
||||||
|
}
|
||||||
|
|
||||||
|
val subjectRichParameters = ncNotification.subjectRichParameters
|
||||||
|
|
||||||
|
pushMessage.timestamp = ncNotification.datetime!!.millis
|
||||||
|
|
||||||
|
if (subjectRichParameters != null && subjectRichParameters.size > 0) {
|
||||||
|
val callHashMap = subjectRichParameters["call"]
|
||||||
|
val userHashMap = subjectRichParameters["user"]
|
||||||
|
val guestHashMap = subjectRichParameters["guest"]
|
||||||
|
if (callHashMap != null && callHashMap.size > 0 && callHashMap.containsKey("name")) {
|
||||||
|
if (subjectRichParameters.containsKey("reaction")) {
|
||||||
|
pushMessage.subject = ""
|
||||||
|
pushMessage.text = ncNotification.subject
|
||||||
|
} else if (ncNotification.objectType == "chat") {
|
||||||
|
pushMessage.subject = callHashMap["name"]!!
|
||||||
|
} else {
|
||||||
|
pushMessage.subject = ncNotification.subject!!
|
||||||
|
}
|
||||||
|
if (callHashMap.containsKey("call-type")) {
|
||||||
|
conversationType = callHashMap["call-type"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val notificationUser = NotificationUser()
|
||||||
|
if (userHashMap != null && userHashMap.isNotEmpty()) {
|
||||||
|
notificationUser.id = userHashMap["id"]
|
||||||
|
notificationUser.type = userHashMap["type"]
|
||||||
|
notificationUser.name = userHashMap["name"]
|
||||||
|
pushMessage.notificationUser = notificationUser
|
||||||
|
} else if (guestHashMap != null && guestHashMap.isNotEmpty()) {
|
||||||
|
notificationUser.id = guestHashMap["id"]
|
||||||
|
notificationUser.type = guestHashMap["type"]
|
||||||
|
notificationUser.name = guestHashMap["name"]
|
||||||
|
pushMessage.notificationUser = notificationUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pushMessage.objectId = ncNotification.objectId
|
||||||
|
showNotification(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(e: Throwable) {
|
||||||
|
// unused atm
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onComplete() {
|
||||||
|
// unused atm
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
private fun showNotification(intent: Intent) {
|
||||||
|
val largeIcon: Bitmap
|
||||||
|
val priority = NotificationCompat.PRIORITY_HIGH
|
||||||
|
val smallIcon: Int = R.drawable.ic_logo
|
||||||
|
val category: String = if (CHAT == pushMessage.type || ROOM == pushMessage.type) {
|
||||||
|
Notification.CATEGORY_MESSAGE
|
||||||
|
} else {
|
||||||
|
Notification.CATEGORY_CALL
|
||||||
|
}
|
||||||
|
when (conversationType) {
|
||||||
|
"one2one" -> {
|
||||||
|
pushMessage.subject = ""
|
||||||
|
largeIcon = ContextCompat.getDrawable(context!!, R.drawable.ic_people_group_black_24px)?.toBitmap()!!
|
||||||
|
}
|
||||||
|
"group" ->
|
||||||
|
largeIcon = ContextCompat.getDrawable(context!!, R.drawable.ic_people_group_black_24px)?.toBitmap()!!
|
||||||
|
"public" -> largeIcon = ContextCompat.getDrawable(context!!, R.drawable.ic_link_black_24px)?.toBitmap()!!
|
||||||
|
else -> // assuming one2one
|
||||||
|
largeIcon = if (CHAT == pushMessage.type || ROOM == pushMessage.type) {
|
||||||
|
ContextCompat.getDrawable(context!!, R.drawable.ic_comment)?.toBitmap()!!
|
||||||
|
} else {
|
||||||
|
ContextCompat.getDrawable(context!!, R.drawable.ic_call_black_24dp)?.toBitmap()!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use unique request code to make sure that a new PendingIntent gets created for each notification
|
||||||
|
// See https://github.com/nextcloud/talk-android/issues/2111
|
||||||
|
val requestCode = System.currentTimeMillis().toInt()
|
||||||
|
val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
PendingIntent.FLAG_MUTABLE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(context, requestCode, intent, intentFlag)
|
||||||
|
val uri = Uri.parse(signatureVerification.user!!.baseUrl)
|
||||||
|
val baseUrl = uri.host
|
||||||
|
val notificationBuilder = NotificationCompat.Builder(context!!, "1")
|
||||||
|
.setLargeIcon(largeIcon)
|
||||||
|
.setSmallIcon(smallIcon)
|
||||||
|
.setCategory(category)
|
||||||
|
.setPriority(priority)
|
||||||
|
.setSubText(baseUrl)
|
||||||
|
.setWhen(pushMessage.timestamp)
|
||||||
|
.setShowWhen(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
if (!TextUtils.isEmpty(pushMessage.subject)) {
|
||||||
|
notificationBuilder.setContentTitle(
|
||||||
|
EmojiCompat.get().process(pushMessage.subject)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!TextUtils.isEmpty(pushMessage.text)) {
|
||||||
|
notificationBuilder.setContentText(
|
||||||
|
EmojiCompat.get().process(pushMessage.text!!)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= 23) {
|
||||||
|
// This method should exist since API 21, but some phones don't have it
|
||||||
|
// So as a safeguard, we don't use it until 23
|
||||||
|
notificationBuilder.color = context!!.resources.getColor(R.color.colorPrimary)
|
||||||
|
}
|
||||||
|
val notificationInfoBundle = Bundle()
|
||||||
|
notificationInfoBundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!)
|
||||||
|
// could be an ID or a TOKEN
|
||||||
|
notificationInfoBundle.putString(KEY_ROOM_TOKEN, pushMessage.id)
|
||||||
|
notificationInfoBundle.putLong(KEY_NOTIFICATION_ID, pushMessage.notificationId!!)
|
||||||
|
notificationBuilder.setExtras(notificationInfoBundle)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
if (CHAT == pushMessage.type || ROOM == pushMessage.type) {
|
||||||
|
notificationBuilder.setChannelId(
|
||||||
|
NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// red color for the lights
|
||||||
|
notificationBuilder.setLights(-0x10000, 200, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationBuilder.setContentIntent(pendingIntent)
|
||||||
|
val groupName = signatureVerification.user!!.id.toString() + "@" + pushMessage.id
|
||||||
|
notificationBuilder.setGroup(calculateCRC32(groupName).toString())
|
||||||
|
val activeStatusBarNotification = findNotificationForRoom(
|
||||||
|
context,
|
||||||
|
signatureVerification.user!!,
|
||||||
|
pushMessage.id!!
|
||||||
|
)
|
||||||
|
|
||||||
|
// NOTE - systemNotificationId is an internal ID used on the device only.
|
||||||
|
// It is NOT the same as the notification ID used in communication with the server.
|
||||||
|
val systemNotificationId: Int =
|
||||||
|
activeStatusBarNotification?.id ?: calculateCRC32(System.currentTimeMillis().toString()).toInt()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && CHAT == pushMessage.type &&
|
||||||
|
pushMessage.notificationUser != null
|
||||||
|
) {
|
||||||
|
prepareChatNotification(notificationBuilder, activeStatusBarNotification, systemNotificationId)
|
||||||
|
}
|
||||||
|
sendNotification(systemNotificationId, notificationBuilder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateCRC32(s: String): Long {
|
||||||
|
val crc32 = CRC32()
|
||||||
|
crc32.update(s.toByteArray())
|
||||||
|
return crc32.value
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(api = Build.VERSION_CODES.N)
|
||||||
|
private fun prepareChatNotification(
|
||||||
|
notificationBuilder: NotificationCompat.Builder,
|
||||||
|
activeStatusBarNotification: StatusBarNotification?,
|
||||||
|
systemNotificationId: Int
|
||||||
|
) {
|
||||||
|
val notificationUser = pushMessage.notificationUser
|
||||||
|
val userType = notificationUser!!.type
|
||||||
|
var style: NotificationCompat.MessagingStyle? = null
|
||||||
|
if (activeStatusBarNotification != null) {
|
||||||
|
style = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(
|
||||||
|
activeStatusBarNotification.notification
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val person = Person.Builder()
|
||||||
|
.setKey(signatureVerification.user!!.id.toString() + "@" + notificationUser.id)
|
||||||
|
.setName(EmojiCompat.get().process(notificationUser.name!!))
|
||||||
|
.setBot("bot" == userType)
|
||||||
|
notificationBuilder.setOnlyAlertOnce(true)
|
||||||
|
addReplyAction(notificationBuilder, systemNotificationId)
|
||||||
|
addMarkAsReadAction(notificationBuilder, systemNotificationId)
|
||||||
|
|
||||||
|
if ("user" == userType || "guest" == userType) {
|
||||||
|
val baseUrl = signatureVerification.user!!.baseUrl
|
||||||
|
val avatarUrl = if ("user" == userType) ApiUtils.getUrlForAvatar(
|
||||||
|
baseUrl,
|
||||||
|
notificationUser.id,
|
||||||
|
false
|
||||||
|
) else ApiUtils.getUrlForGuestAvatar(baseUrl, notificationUser.name, false)
|
||||||
|
person.setIcon(loadAvatarSync(avatarUrl))
|
||||||
|
}
|
||||||
|
notificationBuilder.setStyle(getStyle(person.build(), style))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildIntentForAction(cls: Class<*>, systemNotificationId: Int, messageId: Int): PendingIntent {
|
||||||
|
val actualIntent = Intent(context, cls)
|
||||||
|
|
||||||
|
// NOTE - systemNotificationId is an internal ID used on the device only.
|
||||||
|
// It is NOT the same as the notification ID used in communication with the server.
|
||||||
|
actualIntent.putExtra(KEY_SYSTEM_NOTIFICATION_ID, systemNotificationId)
|
||||||
|
actualIntent.putExtra(KEY_INTERNAL_USER_ID, signatureVerification.user?.id)
|
||||||
|
actualIntent.putExtra(KEY_ROOM_TOKEN, pushMessage.id)
|
||||||
|
actualIntent.putExtra(KEY_MESSAGE_ID, messageId)
|
||||||
|
|
||||||
|
val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
return PendingIntent.getBroadcast(context, systemNotificationId, actualIntent, intentFlag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addMarkAsReadAction(notificationBuilder: NotificationCompat.Builder, systemNotificationId: Int) {
|
||||||
|
if (pushMessage.objectId != null) {
|
||||||
|
val messageId: Int = try {
|
||||||
|
parseMessageId(pushMessage.objectId!!)
|
||||||
|
} catch (nfe: NumberFormatException) {
|
||||||
|
Log.e(TAG, "Failed to parse messageId from objectId, skip adding mark-as-read action.", nfe)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingIntent = buildIntentForAction(
|
||||||
|
MarkAsReadReceiver::class.java,
|
||||||
|
systemNotificationId,
|
||||||
|
messageId
|
||||||
|
)
|
||||||
|
val action = NotificationCompat.Action.Builder(
|
||||||
|
R.drawable.ic_eye,
|
||||||
|
context!!.resources.getString(R.string.nc_mark_as_read),
|
||||||
|
pendingIntent
|
||||||
|
)
|
||||||
|
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
||||||
|
.setShowsUserInterface(false)
|
||||||
|
.build()
|
||||||
|
notificationBuilder.addAction(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(api = Build.VERSION_CODES.N)
|
||||||
|
private fun addReplyAction(notificationBuilder: NotificationCompat.Builder, systemNotificationId: Int) {
|
||||||
|
val replyLabel = context!!.resources.getString(R.string.nc_reply)
|
||||||
|
val remoteInput = RemoteInput.Builder(NotificationUtils.KEY_DIRECT_REPLY)
|
||||||
|
.setLabel(replyLabel)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val replyPendingIntent = buildIntentForAction(DirectReplyReceiver::class.java, systemNotificationId, 0)
|
||||||
|
val replyAction = NotificationCompat.Action.Builder(R.drawable.ic_reply, replyLabel, replyPendingIntent)
|
||||||
|
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||||
|
.setShowsUserInterface(false)
|
||||||
|
.setAllowGeneratedReplies(true)
|
||||||
|
.addRemoteInput(remoteInput)
|
||||||
|
.build()
|
||||||
|
notificationBuilder.addAction(replyAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(api = Build.VERSION_CODES.N)
|
||||||
|
private fun getStyle(person: Person, style: NotificationCompat.MessagingStyle?): NotificationCompat.MessagingStyle {
|
||||||
|
val newStyle = NotificationCompat.MessagingStyle(person)
|
||||||
|
newStyle.conversationTitle = pushMessage.subject
|
||||||
|
newStyle.isGroupConversation = "one2one" != conversationType
|
||||||
|
style?.messages?.forEach(
|
||||||
|
Consumer { message: NotificationCompat.MessagingStyle.Message ->
|
||||||
|
newStyle.addMessage(
|
||||||
|
NotificationCompat.MessagingStyle.Message(
|
||||||
|
message.text,
|
||||||
|
message.timestamp,
|
||||||
|
message.person
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
newStyle.addMessage(pushMessage.text, pushMessage.timestamp, person)
|
||||||
|
return newStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(NumberFormatException::class)
|
||||||
|
private fun parseMessageId(objectId: String): Int {
|
||||||
|
val objectIdParts = objectId.split("/".toRegex()).toTypedArray()
|
||||||
|
return if (objectIdParts.size < 2) {
|
||||||
|
throw NumberFormatException("Invalid objectId, doesn't contain at least one '/'")
|
||||||
|
} else {
|
||||||
|
objectIdParts[1].toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendNotification(notificationId: Int, notification: Notification) {
|
||||||
|
Log.d(TAG, "show notification with id $notificationId")
|
||||||
|
notificationManager.notify(notificationId, notification)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
// On devices with Android 8.0 (Oreo) or later, notification sound will be handled by the system
|
||||||
|
// if notifications have not been disabled by the user.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Notification.CATEGORY_CALL != notification.category || !muteCall) {
|
||||||
|
val soundUri = getMessageRingtoneUri(context!!, appPreferences)
|
||||||
|
if (soundUri != null && !ApplicationWideCurrentRoomHolder.getInstance().isInCall &&
|
||||||
|
(shouldPlaySound() || importantConversation)
|
||||||
|
) {
|
||||||
|
val audioAttributesBuilder =
|
||||||
|
AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
if (CHAT == pushMessage.type || ROOM == pushMessage.type) {
|
||||||
|
audioAttributesBuilder.setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT)
|
||||||
|
} else {
|
||||||
|
audioAttributesBuilder.setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_REQUEST)
|
||||||
|
}
|
||||||
|
val mediaPlayer = MediaPlayer()
|
||||||
|
try {
|
||||||
|
mediaPlayer.setDataSource(context!!, soundUri)
|
||||||
|
mediaPlayer.setAudioAttributes(audioAttributesBuilder.build())
|
||||||
|
mediaPlayer.setOnPreparedListener { mediaPlayer.start() }
|
||||||
|
mediaPlayer.setOnCompletionListener { obj: MediaPlayer -> obj.release() }
|
||||||
|
mediaPlayer.prepareAsync()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Failed to set data source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeNotification(notificationId: Int) {
|
||||||
|
Log.d(TAG, "removed notification with id $notificationId")
|
||||||
|
notificationManager.cancel(notificationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkIfCallIsActive(
|
||||||
|
signatureVerification: SignatureVerification,
|
||||||
|
decryptedPushMessage: DecryptedPushMessage
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "checkIfCallIsActive")
|
||||||
|
var hasParticipantsInCall = true
|
||||||
|
var inCallOnDifferentDevice = false
|
||||||
|
|
||||||
|
val apiVersion = ApiUtils.getConversationApiVersion(
|
||||||
|
signatureVerification.user,
|
||||||
|
intArrayOf(ApiUtils.APIv4, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
var isCallNotificationVisible = true
|
||||||
|
|
||||||
|
ncApi.getPeersForCall(
|
||||||
|
credentials,
|
||||||
|
ApiUtils.getUrlForCall(
|
||||||
|
apiVersion,
|
||||||
|
signatureVerification.user!!.baseUrl,
|
||||||
|
decryptedPushMessage.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.repeatWhen { completed ->
|
||||||
|
completed.zipWith(Observable.range(TIMER_START, TIMER_COUNT)) { _, i -> i }
|
||||||
|
.flatMap { Observable.timer(TIMER_DELAY, TimeUnit.SECONDS) }
|
||||||
|
.takeWhile { isCallNotificationVisible && hasParticipantsInCall && !inCallOnDifferentDevice }
|
||||||
|
}
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe(object : Observer<ParticipantsOverall> {
|
||||||
|
override fun onSubscribe(d: Disposable) = Unit
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
override fun onNext(participantsOverall: ParticipantsOverall) {
|
||||||
|
val participantList: List<Participant> = participantsOverall.ocs!!.data!!
|
||||||
|
hasParticipantsInCall = participantList.isNotEmpty()
|
||||||
|
if (hasParticipantsInCall) {
|
||||||
|
for (participant in participantList) {
|
||||||
|
if (participant.actorId == signatureVerification.user!!.userId &&
|
||||||
|
participant.actorType == Participant.ActorType.USERS
|
||||||
|
) {
|
||||||
|
inCallOnDifferentDevice = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inCallOnDifferentDevice) {
|
||||||
|
Log.d(TAG, "inCallOnDifferentDevice is true")
|
||||||
|
removeNotification(decryptedPushMessage.timestamp.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasParticipantsInCall) {
|
||||||
|
showMissedCallNotification()
|
||||||
|
Log.d(TAG, "no participants in call")
|
||||||
|
removeNotification(decryptedPushMessage.timestamp.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
isCallNotificationVisible = isCallNotificationVisible(decryptedPushMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(e: Throwable) {
|
||||||
|
Log.e(TAG, "Error in getPeersForCall", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
override fun onComplete() {
|
||||||
|
|
||||||
|
if (isCallNotificationVisible) {
|
||||||
|
// this state can be reached when call timeout is reached.
|
||||||
|
showMissedCallNotification()
|
||||||
|
}
|
||||||
|
|
||||||
|
removeNotification(decryptedPushMessage.timestamp.toInt())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showMissedCallNotification() {
|
||||||
|
val apiVersion = ApiUtils.getConversationApiVersion(
|
||||||
|
signatureVerification.user,
|
||||||
|
intArrayOf(
|
||||||
|
ApiUtils.APIv4,
|
||||||
|
ApiUtils.APIv3, 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ncApi.getRoom(
|
||||||
|
credentials,
|
||||||
|
ApiUtils.getUrlForRoom(
|
||||||
|
apiVersion, signatureVerification.user?.baseUrl,
|
||||||
|
pushMessage.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.retry(GET_ROOM_RETRY_COUNT)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(object : Observer<RoomOverall> {
|
||||||
|
override fun onSubscribe(d: Disposable) {
|
||||||
|
// unused atm
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNext(roomOverall: RoomOverall) {
|
||||||
|
val currentConversation = roomOverall.ocs!!.data
|
||||||
|
val notificationBuilder: NotificationCompat.Builder?
|
||||||
|
|
||||||
|
notificationBuilder = NotificationCompat.Builder(
|
||||||
|
context!!,
|
||||||
|
NotificationUtils.NotificationChannels
|
||||||
|
.NOTIFICATION_CHANNEL_MESSAGES_V4.name
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification: Notification = notificationBuilder
|
||||||
|
.setContentTitle(
|
||||||
|
String.format(
|
||||||
|
context!!.resources.getString(R.string.nc_missed_call),
|
||||||
|
currentConversation!!.displayName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setSmallIcon(R.drawable.ic_baseline_phone_missed_24)
|
||||||
|
.setOngoing(false)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setContentIntent(getIntentToOpenConversation())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val notificationId: Int = SystemClock.uptimeMillis().toInt()
|
||||||
|
notificationManager.notify(notificationId, notification)
|
||||||
|
Log.d(TAG, "'you missed a call' notification was created")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(e: Throwable) {
|
||||||
|
Log.e(TAG, "An error occurred while fetching room for the 'missed call' notification", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onComplete() {
|
||||||
|
// unused atm
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getIntentToOpenConversation(): PendingIntent? {
|
||||||
|
val bundle = Bundle()
|
||||||
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
|
bundle.putString(KEY_ROOM_TOKEN, pushMessage.id)
|
||||||
|
bundle.putParcelable(KEY_USER_ENTITY, signatureVerification.user)
|
||||||
|
bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, false)
|
||||||
|
|
||||||
|
intent.putExtras(bundle)
|
||||||
|
|
||||||
|
val requestCode = System.currentTimeMillis().toInt()
|
||||||
|
val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
PendingIntent.FLAG_MUTABLE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(context, requestCode, intent, intentFlag)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
private fun isCallNotificationVisible(decryptedPushMessage: DecryptedPushMessage): Boolean {
|
||||||
|
var isVisible = false
|
||||||
|
|
||||||
|
val notificationManager = context!!.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val notifications = notificationManager.activeNotifications
|
||||||
|
for (notification in notifications) {
|
||||||
|
if (notification.id == decryptedPushMessage.timestamp.toInt()) {
|
||||||
|
isVisible = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isVisible
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val TAG = NotificationWorker::class.simpleName
|
||||||
|
private const val CHAT = "chat"
|
||||||
|
private const val ROOM = "room"
|
||||||
|
private const val SPREED_APP = "spreed"
|
||||||
|
private const val TIMER_START = 1
|
||||||
|
private const val TIMER_COUNT = 12
|
||||||
|
private const val TIMER_DELAY: Long = 5
|
||||||
|
private const val GET_ROOM_RETRY_COUNT: Long = 3
|
||||||
|
}
|
||||||
|
}
|
@ -128,7 +128,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
|
|||||||
val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull()
|
val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull()
|
||||||
|
|
||||||
uploadSuccess = ChunkedFileUploader(
|
uploadSuccess = ChunkedFileUploader(
|
||||||
okHttpClient!!,
|
okHttpClient,
|
||||||
currentUser,
|
currentUser,
|
||||||
roomToken,
|
roomToken,
|
||||||
metaData,
|
metaData,
|
||||||
|
@ -56,7 +56,7 @@ data class Notification(
|
|||||||
@JsonField(name = ["messageRich"])
|
@JsonField(name = ["messageRich"])
|
||||||
var messageRich: String?,
|
var messageRich: String?,
|
||||||
@JsonField(name = ["messageRichParameters"])
|
@JsonField(name = ["messageRichParameters"])
|
||||||
var messageRichParameters: HashMap<String, HashMap<String, String>>?,
|
var messageRichParameters: HashMap<String?, HashMap<String?, String?>>?,
|
||||||
@JsonField(name = ["link"])
|
@JsonField(name = ["link"])
|
||||||
var link: String?,
|
var link: String?,
|
||||||
@JsonField(name = ["actions"])
|
@JsonField(name = ["actions"])
|
||||||
|
@ -24,6 +24,8 @@ package com.nextcloud.talk.models.json.notifications
|
|||||||
import com.bluelinelabs.logansquare.annotation.JsonField
|
import com.bluelinelabs.logansquare.annotation.JsonField
|
||||||
import com.bluelinelabs.logansquare.annotation.JsonObject
|
import com.bluelinelabs.logansquare.annotation.JsonObject
|
||||||
|
|
||||||
|
// see https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md
|
||||||
|
|
||||||
@JsonObject
|
@JsonObject
|
||||||
data class NotificationOverall(
|
data class NotificationOverall(
|
||||||
@JsonField(name = ["ocs"])
|
@JsonField(name = ["ocs"])
|
||||||
|
@ -70,6 +70,7 @@ data class DecryptedPushMessage(
|
|||||||
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
|
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
|
||||||
constructor() : this(null, null, "", null, 0, null, false, false, false, null, null, 0, null)
|
constructor() : this(null, null, "", null, 0, null, false, false, false, null, null, 0, null)
|
||||||
|
|
||||||
|
@Suppress("Detekt.ComplexMethod")
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
if (javaClass != other?.javaClass) return false
|
if (javaClass != other?.javaClass) return false
|
||||||
|
@ -391,6 +391,7 @@ public class ApiUtils {
|
|||||||
getApplicationContext().getResources().getString(R.string.nc_push_server_url) + "/devices";
|
getApplicationContext().getResources().getString(R.string.nc_push_server_url) + "/devices";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// see https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md
|
||||||
public static String getUrlForNotificationWithId(String baseUrl, String notificationId) {
|
public static String getUrlForNotificationWithId(String baseUrl, String notificationId) {
|
||||||
return baseUrl + ocsApiVersion + "/apps/notifications/api/v2/notifications/" + notificationId;
|
return baseUrl + ocsApiVersion + "/apps/notifications/api/v2/notifications/" + notificationId;
|
||||||
}
|
}
|
||||||
|
@ -47,6 +47,7 @@ import com.nextcloud.talk.utils.bundle.BundleKeys
|
|||||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
|
@Suppress("TooManyFunctions")
|
||||||
object NotificationUtils {
|
object NotificationUtils {
|
||||||
|
|
||||||
enum class NotificationChannels {
|
enum class NotificationChannels {
|
||||||
@ -241,7 +242,7 @@ object NotificationUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelExistingNotificationWithId(context: Context?, conversationUser: User, notificationId: Long?) {
|
fun cancelNotification(context: Context?, conversationUser: User, notificationId: Long?) {
|
||||||
scanNotifications(context, conversationUser) { notificationManager, statusBarNotification, notification ->
|
scanNotifications(context, conversationUser) { notificationManager, statusBarNotification, notification ->
|
||||||
if (notificationId == notification.extras.getLong(BundleKeys.KEY_NOTIFICATION_ID)) {
|
if (notificationId == notification.extras.getLong(BundleKeys.KEY_NOTIFICATION_ID)) {
|
||||||
notificationManager.cancel(statusBarNotification.id)
|
notificationManager.cancel(statusBarNotification.id)
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
<vector android:autoMirrored="true" android:height="24dp"
|
||||||
|
android:tint="#000000" android:viewportHeight="24"
|
||||||
|
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M6.5,5.5L12,11l7,-7 -1,-1 -6,6 -4.5,-4.5L11,4.5L11,3L5,3v6h1.5L6.5,5.5zM23.71,16.67C20.66,13.78 16.54,12 12,12 7.46,12 3.34,13.78 0.29,16.67c-0.18,0.18 -0.29,0.43 -0.29,0.71s0.11,0.53 0.29,0.71l2.48,2.48c0.18,0.18 0.43,0.29 0.71,0.29 0.27,0 0.52,-0.11 0.7,-0.28 0.79,-0.74 1.69,-1.36 2.66,-1.85 0.33,-0.16 0.56,-0.5 0.56,-0.9v-3.1c1.45,-0.48 3,-0.73 4.6,-0.73 1.6,0 3.15,0.25 4.6,0.72v3.1c0,0.39 0.23,0.74 0.56,0.9 0.98,0.49 1.87,1.12 2.67,1.85 0.18,0.18 0.43,0.28 0.7,0.28 0.28,0 0.53,-0.11 0.71,-0.29l2.48,-2.48c0.18,-0.18 0.29,-0.43 0.29,-0.71s-0.12,-0.52 -0.3,-0.7z"/>
|
||||||
|
</vector>
|
@ -210,6 +210,7 @@
|
|||||||
<string name="nc_call_state_in_call">%1$s in call</string>
|
<string name="nc_call_state_in_call">%1$s in call</string>
|
||||||
<string name="nc_call_state_with_phone">%1$s with phone</string>
|
<string name="nc_call_state_with_phone">%1$s with phone</string>
|
||||||
<string name="nc_call_state_with_video">%1$s with video</string>
|
<string name="nc_call_state_with_video">%1$s with video</string>
|
||||||
|
<string name="nc_missed_call">You missed a call from %s</string>
|
||||||
|
|
||||||
<!-- Picture in Picture -->
|
<!-- Picture in Picture -->
|
||||||
<string name="nc_pip_microphone_mute">Mute microphone</string>
|
<string name="nc_pip_microphone_mute">Mute microphone</string>
|
||||||
@ -246,7 +247,6 @@
|
|||||||
<string name="nc_share_subject">%1$s invitation</string>
|
<string name="nc_share_subject">%1$s invitation</string>
|
||||||
<string name="nc_share_text_pass">\nPassword: %1$s</string>
|
<string name="nc_share_text_pass">\nPassword: %1$s</string>
|
||||||
|
|
||||||
<!-- Magical stuff -->
|
|
||||||
<string name="nc_push_to_talk">Push-to-talk</string>
|
<string name="nc_push_to_talk">Push-to-talk</string>
|
||||||
<string name="nc_push_to_talk_desc">With microphone disabled, click&hold to use Push-to-talk</string>
|
<string name="nc_push_to_talk_desc">With microphone disabled, click&hold to use Push-to-talk</string>
|
||||||
<string name="nc_configure_cert_auth">Select authentication certificate</string>
|
<string name="nc_configure_cert_auth">Select authentication certificate</string>
|
||||||
|
Loading…
Reference in New Issue
Block a user