diff --git a/app/src/gplay/AndroidManifest.xml b/app/src/gplay/AndroidManifest.xml index 7da9db5a5..7661d02bd 100644 --- a/app/src/gplay/AndroidManifest.xml +++ b/app/src/gplay/AndroidManifest.xml @@ -39,7 +39,7 @@ diff --git a/app/src/gplay/java/com/nextcloud/talk/services/firebase/ChatAndCallMessagingService.kt b/app/src/gplay/java/com/nextcloud/talk/services/firebase/ChatAndCallMessagingService.kt deleted file mode 100644 index 26086c0aa..000000000 --- a/app/src/gplay/java/com/nextcloud/talk/services/firebase/ChatAndCallMessagingService.kt +++ /dev/null @@ -1,327 +0,0 @@ -/* - * Nextcloud Talk application - * - * @author Mario Danic - * @author Tim Krüger - * Copyright (C) 2022 Tim Krüger - * Copyright (C) 2017-2019 Mario Danic - * - * 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 . - */ -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 { - override fun onSubscribe(d: Disposable) = Unit - - override fun onNext(participantsOverall: ParticipantsOverall) { - val participantList: List = 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 - } -} diff --git a/app/src/gplay/java/com/nextcloud/talk/services/firebase/NCFirebaseMessagingService.kt b/app/src/gplay/java/com/nextcloud/talk/services/firebase/NCFirebaseMessagingService.kt new file mode 100644 index 000000000..04f61b052 --- /dev/null +++ b/app/src/gplay/java/com/nextcloud/talk/services/firebase/NCFirebaseMessagingService.kt @@ -0,0 +1,97 @@ +/* + * Nextcloud Talk application + * + * @author Mario Danic + * @author Tim Krüger + * @author Marcel Hibbe + * Copyright (C) 2022 Marcel Hibbe + * Copyright (C) 2022 Tim Krüger + * Copyright (C) 2017-2019 Mario Danic + * + * 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 . + */ +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" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallNotificationActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallNotificationActivity.java deleted file mode 100644 index e07491dc1..000000000 --- a/app/src/main/java/com/nextcloud/talk/activities/CallNotificationActivity.java +++ /dev/null @@ -1,451 +0,0 @@ -/* - * Nextcloud Talk application - * - * @author Mario Danic - * Copyright (C) 2017-2018 Mario Danic - * - * 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 . - */ - -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 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() { - @Override - public void onSubscribe(@NonNull Disposable d) { - disposablesList.add(d); - } - - @Override - public void onNext(@NonNull ParticipantsOverall participantsOverall) { - boolean hasParticipantsInCall = false; - boolean inCallOnDifferentDevice = false; - List 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() { - @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> 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> 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); - } -} \ No newline at end of file diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallNotificationActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallNotificationActivity.kt new file mode 100644 index 000000000..3d30e3a0c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/activities/CallNotificationActivity.kt @@ -0,0 +1,476 @@ +/* + * Nextcloud Talk application + * + * @author Mario Danic + * @author Marcel Hibbe + * Copyright (C) 2022 Marcel Hibbe + * Copyright (C) 2017-2018 Mario Danic + * + * 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 . + */ +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 = 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 -> + completed.zipWith(Observable.range(TIMER_START, TIMER_COUNT)) { _: Any?, i: Int? -> i!! } + .flatMap { Observable.timer(TIMER_DELAY, TimeUnit.SECONDS) } + .takeWhile { !leavingScreen } + } + .subscribe(object : Observer { + 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 { + 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>) { + 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 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/events/CallNotificationClick.kt b/app/src/main/java/com/nextcloud/talk/events/CallNotificationClick.kt deleted file mode 100644 index ad8c52fde..000000000 --- a/app/src/main/java/com/nextcloud/talk/events/CallNotificationClick.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Nextcloud Talk application - * - * @author Mario Danic - * Copyright (C) 2017-2019 Mario Danic - * - * 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 . - */ - -package com.nextcloud.talk.events - -class CallNotificationClick diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.java b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.java deleted file mode 100644 index e946511de..000000000 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.java +++ /dev/null @@ -1,695 +0,0 @@ -/* - * Nextcloud Talk application - * - * @author Andy Scherzinger - * @author Mario Danic - * Copyright (C) 2022 Andy Scherzinger - * Copyright (C) 2017-2018 Mario Danic - * - * 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 . - */ - -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() { - @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() { - @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> subjectRichParameters = notification - .getSubjectRichParameters(); - - decryptedPushMessage.setTimestamp(notification.getDatetime().getMillis()); - - if (subjectRichParameters != null && subjectRichParameters.size() > 0) { - HashMap callHashMap = subjectRichParameters.get("call"); - HashMap userHashMap = subjectRichParameters.get("user"); - HashMap 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(); - } -} diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt new file mode 100644 index 000000000..2d7dc11b7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -0,0 +1,849 @@ +/* + * Nextcloud Talk application + * + * @author Andy Scherzinger + * @author Mario Danic + * @author Marcel Hibbe + * Copyright (C) 2022 Marcel Hibbe + * Copyright (C) 2022 Andy Scherzinger + * Copyright (C) 2017-2018 Mario Danic + * + * 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 . + */ +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 { + 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 { + override fun onSubscribe(d: Disposable) = Unit + + @RequiresApi(Build.VERSION_CODES.M) + override fun onNext(participantsOverall: ParticipantsOverall) { + val participantList: List = 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 { + 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 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt index a58126b22..5be620dd3 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -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, diff --git a/app/src/main/java/com/nextcloud/talk/models/json/notifications/Notification.kt b/app/src/main/java/com/nextcloud/talk/models/json/notifications/Notification.kt index 0d9f1e8f8..6df6f3af1 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/notifications/Notification.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/notifications/Notification.kt @@ -56,7 +56,7 @@ data class Notification( @JsonField(name = ["messageRich"]) var messageRich: String?, @JsonField(name = ["messageRichParameters"]) - var messageRichParameters: HashMap>?, + var messageRichParameters: HashMap>?, @JsonField(name = ["link"]) var link: String?, @JsonField(name = ["actions"]) diff --git a/app/src/main/java/com/nextcloud/talk/models/json/notifications/NotificationOverall.kt b/app/src/main/java/com/nextcloud/talk/models/json/notifications/NotificationOverall.kt index 2e20a6349..5c5fb2287 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/notifications/NotificationOverall.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/notifications/NotificationOverall.kt @@ -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"]) diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt index 001305b51..264184a75 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt @@ -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 diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java index c12c04572..9e3841544 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java @@ -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; } diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index 4341f18c1..62bbc837c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -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) diff --git a/app/src/main/res/drawable/ic_baseline_phone_missed_24.xml b/app/src/main/res/drawable/ic_baseline_phone_missed_24.xml new file mode 100644 index 000000000..7928dce1a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_phone_missed_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef4df1cff..8f674a172 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -210,6 +210,7 @@ %1$s in call %1$s with phone %1$s with video + You missed a call from %s Mute microphone @@ -246,7 +247,6 @@ %1$s invitation \nPassword: %1$s - Push-to-talk With microphone disabled, click&hold to use Push-to-talk Select authentication certificate