Merge pull request #2415 from nextcloud/feature/1724/missedCallNotification

Feature/1724/missed call notification
This commit is contained in:
Marcel Hibbe 2022-11-11 12:48:50 +01:00 committed by GitHub
commit 263edbc1d0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1437 additions and 1501 deletions

View file

@ -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>

View file

@ -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
}
}

View file

@ -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"
}
}

View file

@ -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);
}
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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();
}
}

View 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
}
}

View file

@ -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,

View file

@ -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"])

View file

@ -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"])

View file

@ -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

View file

@ -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;
}

View file

@ -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)

View file

@ -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>

View file

@ -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&amp;hold to use Push-to-talk</string>
<string name="nc_configure_cert_auth">Select authentication certificate</string>