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