mirror of
https://github.com/nextcloud/talk-android.git
synced 2024-11-26 23:25:20 +03:00
add missed call notification, show delivery delay in debug mode
- add missed call notifications in NotificationWorker and CallNotificationActivity - introduce refactoring of Notification handling (isolate firebase stuff from other logic). All "UI-notification" logic from ChatAndCallMessagingService was moved to NotificationWorker. ChatAndCallMessagingService was renamed to NCFirebaseMessagingService because it is now only responsible for firebase stuff. This separation should make it easier for alternative push services to dock with the app (if they are incorporated in the future). - for DEBUG mode: show delivery delay time in notifications (time between sending from firebase to receive on device). Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
This commit is contained in:
parent
c212a86fe9
commit
3a517f5760
16 changed files with 1459 additions and 1501 deletions
|
@ -39,7 +39,7 @@
|
|||
<meta-data android:name="google_analytics_adid_collection_enabled" android:value="false" />
|
||||
|
||||
<service
|
||||
android:name=".services.firebase.ChatAndCallMessagingService"
|
||||
android:name=".services.firebase.NCFirebaseMessagingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="phoneCall">
|
||||
<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,98 @@
|
|||
/*
|
||||
* 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)
|
||||
.putLong(BundleKeys.KEY_NOTIFICATION_SENT_TIME, remoteMessage.sentTime)
|
||||
.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,474 @@
|
|||
/*
|
||||
* 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)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
873
app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt
Normal file
873
app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt
Normal file
|
@ -0,0 +1,873 @@
|
|||
/*
|
||||
* 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.BuildConfig
|
||||
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 var deliveryDelayTime: Long = 0
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
|
||||
override fun doWork(): Result {
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
context = applicationContext
|
||||
|
||||
calculateDeliveryDelayTime(inputData)
|
||||
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 + getDeliveryDelayTimeForDebug())
|
||||
)
|
||||
.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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the time between the sent time (from firebase) and the received time on the device.
|
||||
* 'deliveryDelayTime' is displayed in debug mode right after the notification message.
|
||||
* A huge delay means that there might be something wrong on device side.
|
||||
*/
|
||||
private fun calculateDeliveryDelayTime(inputData: Data) {
|
||||
val messageSentTime = inputData.getLong(BundleKeys.KEY_NOTIFICATION_SENT_TIME, 0)
|
||||
deliveryDelayTime = if (messageSentTime == 0L) {
|
||||
0
|
||||
} else {
|
||||
System.currentTimeMillis() - messageSentTime
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 + getDeliveryDelayTimeForDebug())
|
||||
)
|
||||
}
|
||||
if (!TextUtils.isEmpty(pushMessage.text)) {
|
||||
notificationBuilder.setContentText(
|
||||
EmojiCompat.get().process(pushMessage.text!! + getDeliveryDelayTimeForDebug())
|
||||
)
|
||||
}
|
||||
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 + getDeliveryDelayTimeForDebug(), pushMessage.timestamp, person)
|
||||
return newStyle
|
||||
}
|
||||
|
||||
private fun getDeliveryDelayTimeForDebug(): String {
|
||||
if (BuildConfig.DEBUG) {
|
||||
return " ($deliveryDelayTime ms delay)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@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
|
||||
) + getDeliveryDelayTimeForDebug()
|
||||
)
|
||||
.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()
|
||||
|
||||
uploadSuccess = ChunkedFileUploader(
|
||||
okHttpClient!!,
|
||||
okHttpClient,
|
||||
currentUser,
|
||||
roomToken,
|
||||
metaData,
|
||||
|
|
|
@ -56,7 +56,7 @@ data class Notification(
|
|||
@JsonField(name = ["messageRich"])
|
||||
var messageRich: String?,
|
||||
@JsonField(name = ["messageRichParameters"])
|
||||
var messageRichParameters: HashMap<String, HashMap<String, String>>?,
|
||||
var messageRichParameters: HashMap<String?, HashMap<String?, String?>>?,
|
||||
@JsonField(name = ["link"])
|
||||
var link: String?,
|
||||
@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.JsonObject
|
||||
|
||||
// see https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md
|
||||
|
||||
@JsonObject
|
||||
data class NotificationOverall(
|
||||
@JsonField(name = ["ocs"])
|
||||
|
|
|
@ -391,6 +391,7 @@ public class ApiUtils {
|
|||
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) {
|
||||
return baseUrl + ocsApiVersion + "/apps/notifications/api/v2/notifications/" + notificationId;
|
||||
}
|
||||
|
|
|
@ -241,7 +241,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 ->
|
||||
if (notificationId == notification.extras.getLong(BundleKeys.KEY_NOTIFICATION_ID)) {
|
||||
notificationManager.cancel(statusBarNotification.id)
|
||||
|
|
|
@ -48,6 +48,7 @@ object BundleKeys {
|
|||
const val KEY_MODIFIED_BASE_URL = "KEY_MODIFIED_BASE_URL"
|
||||
const val KEY_NOTIFICATION_SUBJECT = "KEY_NOTIFICATION_SUBJECT"
|
||||
const val KEY_NOTIFICATION_SIGNATURE = "KEY_NOTIFICATION_SIGNATURE"
|
||||
const val KEY_NOTIFICATION_SENT_TIME = "KEY_NOTIFICATION_SENT_TIME"
|
||||
const val KEY_INTERNAL_USER_ID = "KEY_INTERNAL_USER_ID"
|
||||
const val KEY_CONVERSATION_TYPE = "KEY_CONVERSATION_TYPE"
|
||||
const val KEY_INVITED_PARTICIPANTS = "KEY_INVITED_PARTICIPANTS"
|
||||
|
|
|
@ -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_with_phone">%1$s with phone</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 -->
|
||||
<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_text_pass">\nPassword: %1$s</string>
|
||||
|
||||
<!-- Magical stuff -->
|
||||
<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_configure_cert_auth">Select authentication certificate</string>
|
||||
|
|
Loading…
Reference in a new issue