Kaynağa Gözat

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

Feature/1724/missed call notification
Marcel Hibbe 2 yıl önce
ebeveyn
işleme
263edbc1d0

+ 1 - 1
app/src/gplay/AndroidManifest.xml

@@ -39,7 +39,7 @@
         <meta-data android:name="google_analytics_adid_collection_enabled" android:value="false" />
 
         <service
-            android:name=".services.firebase.ChatAndCallMessagingService"
+            android:name=".services.firebase.NCFirebaseMessagingService"
             android:exported="false"
             android:foregroundServiceType="phoneCall">
             <intent-filter>

+ 0 - 327
app/src/gplay/java/com/nextcloud/talk/services/firebase/ChatAndCallMessagingService.kt

@@ -1,327 +0,0 @@
-/*
- * Nextcloud Talk application
- *
- * @author Mario Danic
- * @author Tim Krüger
- * Copyright (C) 2022 Tim Krüger <t@timkrueger.me>
- * Copyright (C) 2017-2019 Mario Danic <mario@lovelyhq.com>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-package com.nextcloud.talk.services.firebase
-
-import android.annotation.SuppressLint
-import android.app.Notification
-import android.app.PendingIntent
-import android.content.Intent
-import android.net.Uri
-import android.os.Build
-import android.os.Bundle
-import android.os.Handler
-import android.util.Base64
-import android.util.Log
-import androidx.core.app.NotificationCompat
-import androidx.emoji.text.EmojiCompat
-import androidx.work.Data
-import androidx.work.OneTimeWorkRequest
-import androidx.work.WorkManager
-import autodagger.AutoInjector
-import com.bluelinelabs.logansquare.LoganSquare
-import com.google.firebase.messaging.FirebaseMessagingService
-import com.google.firebase.messaging.RemoteMessage
-import com.nextcloud.talk.R
-import com.nextcloud.talk.activities.CallNotificationActivity
-import com.nextcloud.talk.api.NcApi
-import com.nextcloud.talk.application.NextcloudTalkApplication
-import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
-import com.nextcloud.talk.events.CallNotificationClick
-import com.nextcloud.talk.jobs.NotificationWorker
-import com.nextcloud.talk.jobs.PushRegistrationWorker
-import com.nextcloud.talk.models.SignatureVerification
-import com.nextcloud.talk.models.json.participants.Participant
-import com.nextcloud.talk.models.json.participants.ParticipantsOverall
-import com.nextcloud.talk.models.json.push.DecryptedPushMessage
-import com.nextcloud.talk.utils.ApiUtils
-import com.nextcloud.talk.utils.NotificationUtils
-import com.nextcloud.talk.utils.NotificationUtils.cancelAllNotificationsForAccount
-import com.nextcloud.talk.utils.NotificationUtils.cancelExistingNotificationWithId
-import com.nextcloud.talk.utils.NotificationUtils.getCallRingtoneUri
-import com.nextcloud.talk.utils.PushUtils
-import com.nextcloud.talk.utils.bundle.BundleKeys
-import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FROM_NOTIFICATION_START_CALL
-import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY
-import com.nextcloud.talk.utils.preferences.AppPreferences
-import io.reactivex.Observable
-import io.reactivex.Observer
-import io.reactivex.disposables.Disposable
-import io.reactivex.schedulers.Schedulers
-import okhttp3.JavaNetCookieJar
-import okhttp3.OkHttpClient
-import org.greenrobot.eventbus.EventBus
-import org.greenrobot.eventbus.Subscribe
-import org.greenrobot.eventbus.ThreadMode
-import retrofit2.Retrofit
-import java.net.CookieManager
-import java.security.InvalidKeyException
-import java.security.NoSuchAlgorithmException
-import java.security.PrivateKey
-import java.util.concurrent.TimeUnit
-import javax.crypto.Cipher
-import javax.crypto.NoSuchPaddingException
-import javax.inject.Inject
-
-@SuppressLint("LongLogTag")
-@AutoInjector(NextcloudTalkApplication::class)
-class ChatAndCallMessagingService : FirebaseMessagingService() {
-
-    @Inject
-    lateinit var appPreferences: AppPreferences
-
-    private var isServiceInForeground: Boolean = false
-    private var decryptedPushMessage: DecryptedPushMessage? = null
-    private var signatureVerification: SignatureVerification? = null
-    private var handler: Handler = Handler()
-
-    @Inject
-    lateinit var retrofit: Retrofit
-
-    @Inject
-    lateinit var okHttpClient: OkHttpClient
-
-    @Inject
-    lateinit var eventBus: EventBus
-
-    override fun onCreate() {
-        super.onCreate()
-        sharedApplication!!.componentApplication.inject(this)
-        eventBus.register(this)
-    }
-
-    @Subscribe(threadMode = ThreadMode.BACKGROUND)
-    fun onMessageEvent(event: CallNotificationClick) {
-        Log.d(TAG, "CallNotification was clicked")
-        isServiceInForeground = false
-        stopForeground(true)
-    }
-
-    override fun onDestroy() {
-        Log.d(TAG, "onDestroy")
-        isServiceInForeground = false
-        eventBus.unregister(this)
-        stopForeground(true)
-        handler.removeCallbacksAndMessages(null)
-        super.onDestroy()
-    }
-
-    override fun onNewToken(token: String) {
-        super.onNewToken(token)
-        sharedApplication!!.componentApplication.inject(this)
-        appPreferences.pushToken = token
-        Log.d(TAG, "onNewToken. token = $token")
-
-        val data: Data = Data.Builder().putString(PushRegistrationWorker.ORIGIN, "onNewToken").build()
-        val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java)
-            .setInputData(data)
-            .build()
-        WorkManager.getInstance().enqueue(pushRegistrationWork)
-    }
-
-    override fun onMessageReceived(remoteMessage: RemoteMessage) {
-        Log.d(TAG, "onMessageReceived")
-        sharedApplication!!.componentApplication.inject(this)
-        if (!remoteMessage.data["subject"].isNullOrEmpty() && !remoteMessage.data["signature"].isNullOrEmpty()) {
-            decryptMessage(remoteMessage.data["subject"]!!, remoteMessage.data["signature"]!!)
-        }
-    }
-
-    @Suppress("Detekt.TooGenericExceptionCaught")
-    private fun decryptMessage(subject: String, signature: String) {
-        try {
-            val base64DecodedSubject = Base64.decode(subject, Base64.DEFAULT)
-            val base64DecodedSignature = Base64.decode(signature, Base64.DEFAULT)
-            val pushUtils = PushUtils()
-            val privateKey = pushUtils.readKeyFromFile(false) as PrivateKey
-            try {
-                signatureVerification = pushUtils.verifySignature(
-                    base64DecodedSignature,
-                    base64DecodedSubject
-                )
-                if (signatureVerification!!.signatureValid) {
-                    decryptMessage(privateKey, base64DecodedSubject, subject, signature)
-                }
-            } catch (e1: NoSuchAlgorithmException) {
-                Log.e(NotificationWorker.TAG, "No proper algorithm to decrypt the message.", e1)
-            } catch (e1: NoSuchPaddingException) {
-                Log.e(NotificationWorker.TAG, "No proper padding to decrypt the message.", e1)
-            } catch (e1: InvalidKeyException) {
-                Log.e(NotificationWorker.TAG, "Invalid private key.", e1)
-            }
-        } catch (exception: Exception) {
-            Log.e(NotificationWorker.TAG, "Something went very wrong!", exception)
-        }
-    }
-
-    private fun decryptMessage(
-        privateKey: PrivateKey,
-        base64DecodedSubject: ByteArray?,
-        subject: String,
-        signature: String
-    ) {
-        val cipher = Cipher.getInstance("RSA/None/PKCS1Padding")
-        cipher.init(Cipher.DECRYPT_MODE, privateKey)
-        val decryptedSubject = cipher.doFinal(base64DecodedSubject)
-        decryptedPushMessage = LoganSquare.parse(
-            String(decryptedSubject),
-            DecryptedPushMessage::class.java
-        )
-        decryptedPushMessage?.apply {
-            Log.d(TAG, this.toString())
-            timestamp = System.currentTimeMillis()
-            if (delete) {
-                cancelExistingNotificationWithId(applicationContext, signatureVerification!!.user!!, notificationId)
-            } else if (deleteAll) {
-                cancelAllNotificationsForAccount(applicationContext, signatureVerification!!.user!!)
-            } else if (deleteMultiple) {
-                notificationIds!!.forEach {
-                    cancelExistingNotificationWithId(applicationContext, signatureVerification!!.user!!, it)
-                }
-            } else if (type == "call") {
-                val fullScreenIntent = Intent(applicationContext, CallNotificationActivity::class.java)
-                val bundle = Bundle()
-                bundle.putString(BundleKeys.KEY_ROOM_ID, decryptedPushMessage!!.id)
-                bundle.putParcelable(KEY_USER_ENTITY, signatureVerification!!.user)
-                bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, true)
-                fullScreenIntent.putExtras(bundle)
-
-                fullScreenIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
-                val fullScreenPendingIntent = PendingIntent.getActivity(
-                    this@ChatAndCallMessagingService,
-                    0,
-                    fullScreenIntent,
-                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-                        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
-                    } else {
-                        PendingIntent.FLAG_UPDATE_CURRENT
-                    }
-                )
-
-                val soundUri = getCallRingtoneUri(applicationContext!!, appPreferences)
-                val notificationChannelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_CALLS_V4.name
-                val uri = Uri.parse(signatureVerification!!.user!!.baseUrl)
-                val baseUrl = uri.host
-
-                val notification =
-                    NotificationCompat.Builder(this@ChatAndCallMessagingService, notificationChannelId)
-                        .setPriority(NotificationCompat.PRIORITY_HIGH)
-                        .setCategory(NotificationCompat.CATEGORY_CALL)
-                        .setSmallIcon(R.drawable.ic_call_black_24dp)
-                        .setSubText(baseUrl)
-                        .setShowWhen(true)
-                        .setWhen(decryptedPushMessage!!.timestamp)
-                        .setContentTitle(EmojiCompat.get().process(decryptedPushMessage!!.subject))
-                        .setAutoCancel(true)
-                        .setOngoing(true)
-                        // .setTimeoutAfter(45000L)
-                        .setContentIntent(fullScreenPendingIntent)
-                        .setFullScreenIntent(fullScreenPendingIntent, true)
-                        .setSound(soundUri)
-                        .build()
-                notification.flags = notification.flags or Notification.FLAG_INSISTENT
-                isServiceInForeground = true
-                checkIfCallIsActive(signatureVerification!!, decryptedPushMessage!!)
-                startForeground(decryptedPushMessage!!.timestamp.toInt(), notification)
-            } else {
-                val messageData = Data.Builder()
-                    .putString(BundleKeys.KEY_NOTIFICATION_SUBJECT, subject)
-                    .putString(BundleKeys.KEY_NOTIFICATION_SIGNATURE, signature)
-                    .build()
-                val pushNotificationWork =
-                    OneTimeWorkRequest.Builder(NotificationWorker::class.java).setInputData(messageData)
-                        .build()
-                WorkManager.getInstance().enqueue(pushNotificationWork)
-            }
-        }
-    }
-
-    private fun checkIfCallIsActive(
-        signatureVerification: SignatureVerification,
-        decryptedPushMessage: DecryptedPushMessage
-    ) {
-        Log.d(TAG, "checkIfCallIsActive")
-        val ncApi = retrofit.newBuilder()
-            .client(okHttpClient.newBuilder().cookieJar(JavaNetCookieJar(CookieManager())).build()).build()
-            .create(NcApi::class.java)
-        var hasParticipantsInCall = true
-        var inCallOnDifferentDevice = false
-
-        val apiVersion = ApiUtils.getConversationApiVersion(
-            signatureVerification.user,
-            intArrayOf(ApiUtils.APIv4, 1)
-        )
-
-        ncApi.getPeersForCall(
-            ApiUtils.getCredentials(
-                signatureVerification.user!!.username,
-                signatureVerification.user!!.token
-            ),
-            ApiUtils.getUrlForCall(
-                apiVersion,
-                signatureVerification.user!!.baseUrl,
-                decryptedPushMessage.id
-            )
-        )
-            .repeatWhen { completed ->
-                completed.zipWith(Observable.range(1, OBSERVABLE_COUNT), { _, i -> i })
-                    .flatMap { Observable.timer(OBSERVABLE_DELAY, TimeUnit.SECONDS) }
-                    .takeWhile { isServiceInForeground && hasParticipantsInCall && !inCallOnDifferentDevice }
-            }
-            .subscribeOn(Schedulers.io())
-            .subscribe(object : Observer<ParticipantsOverall> {
-                override fun onSubscribe(d: Disposable) = Unit
-
-                override fun onNext(participantsOverall: ParticipantsOverall) {
-                    val participantList: List<Participant> = participantsOverall.ocs!!.data!!
-                    hasParticipantsInCall = participantList.isNotEmpty()
-                    if (hasParticipantsInCall) {
-                        for (participant in participantList) {
-                            if (participant.actorId == signatureVerification.user!!.userId &&
-                                participant.actorType == Participant.ActorType.USERS
-                            ) {
-                                inCallOnDifferentDevice = true
-                                break
-                            }
-                        }
-                    }
-                    if (!hasParticipantsInCall || inCallOnDifferentDevice) {
-                        Log.d(TAG, "no participants in call OR inCallOnDifferentDevice")
-                        stopForeground(true)
-                        handler.removeCallbacksAndMessages(null)
-                    }
-                }
-
-                override fun onError(e: Throwable) = Unit
-
-                override fun onComplete() {
-                    stopForeground(true)
-                    handler.removeCallbacksAndMessages(null)
-                }
-            })
-    }
-
-    companion object {
-        private val TAG = ChatAndCallMessagingService::class.simpleName
-        private const val OBSERVABLE_COUNT = 12
-        private const val OBSERVABLE_DELAY: Long = 5
-    }
-}

+ 97 - 0
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 <dev@mhibbe.de>
+ * Copyright (C) 2022 Tim Krüger <t@timkrueger.me>
+ * Copyright (C) 2017-2019 Mario Danic <mario@lovelyhq.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.talk.services.firebase
+
+import android.util.Log
+import androidx.work.Data
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkManager
+import autodagger.AutoInjector
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.jobs.NotificationWorker
+import com.nextcloud.talk.jobs.PushRegistrationWorker
+import com.nextcloud.talk.utils.bundle.BundleKeys
+import com.nextcloud.talk.utils.preferences.AppPreferences
+import javax.inject.Inject
+
+@AutoInjector(NextcloudTalkApplication::class)
+class NCFirebaseMessagingService : FirebaseMessagingService() {
+
+    @Inject
+    lateinit var appPreferences: AppPreferences
+
+    override fun onCreate() {
+        Log.d(TAG, "onCreate")
+        super.onCreate()
+        sharedApplication!!.componentApplication.inject(this)
+    }
+
+    override fun onMessageReceived(remoteMessage: RemoteMessage) {
+        Log.d(TAG, "onMessageReceived")
+        sharedApplication!!.componentApplication.inject(this)
+
+        Log.d(TAG, "remoteMessage.priority: " + remoteMessage.priority)
+        Log.d(TAG, "remoteMessage.originalPriority: " + remoteMessage.originalPriority)
+
+        val data = remoteMessage.data
+        val subject = data[KEY_NOTIFICATION_SUBJECT]
+        val signature = data[KEY_NOTIFICATION_SIGNATURE]
+
+        if (!subject.isNullOrEmpty() && !signature.isNullOrEmpty()) {
+            val messageData = Data.Builder()
+                .putString(BundleKeys.KEY_NOTIFICATION_SUBJECT, subject)
+                .putString(BundleKeys.KEY_NOTIFICATION_SIGNATURE, signature)
+                .build()
+            val notificationWork =
+                OneTimeWorkRequest.Builder(NotificationWorker::class.java).setInputData(messageData)
+                    .build()
+            WorkManager.getInstance().enqueue(notificationWork)
+        }
+    }
+
+    override fun onNewToken(token: String) {
+        super.onNewToken(token)
+        Log.d(TAG, "onNewToken. token = $token")
+
+        appPreferences.pushToken = token
+
+        val data: Data = Data.Builder().putString(
+            PushRegistrationWorker.ORIGIN,
+            "NCFirebaseMessagingService#onNewToken"
+        ).build()
+        val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java)
+            .setInputData(data)
+            .build()
+        WorkManager.getInstance().enqueue(pushRegistrationWork)
+    }
+
+    companion object {
+        private val TAG = NCFirebaseMessagingService::class.simpleName
+        const val KEY_NOTIFICATION_SUBJECT = "subject"
+        const val KEY_NOTIFICATION_SIGNATURE = "signature"
+    }
+}

+ 0 - 451
app/src/main/java/com/nextcloud/talk/activities/CallNotificationActivity.java

@@ -1,451 +0,0 @@
-/*
- * Nextcloud Talk application
- *
- * @author Mario Danic
- * Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package com.nextcloud.talk.activities;
-
-import android.annotation.SuppressLint;
-import android.content.Context;
-import android.content.Intent;
-import android.content.res.Configuration;
-import android.graphics.Bitmap;
-import android.graphics.drawable.BitmapDrawable;
-import android.media.AudioAttributes;
-import android.media.MediaPlayer;
-import android.net.Uri;
-import android.os.Build;
-import android.os.Bundle;
-import android.os.Handler;
-import android.util.Log;
-import android.view.View;
-
-import com.facebook.common.executors.UiThreadImmediateExecutorService;
-import com.facebook.common.references.CloseableReference;
-import com.facebook.datasource.DataSource;
-import com.facebook.drawee.backends.pipeline.Fresco;
-import com.facebook.imagepipeline.core.ImagePipeline;
-import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber;
-import com.facebook.imagepipeline.image.CloseableImage;
-import com.facebook.imagepipeline.request.ImageRequest;
-import com.nextcloud.talk.R;
-import com.nextcloud.talk.api.NcApi;
-import com.nextcloud.talk.application.NextcloudTalkApplication;
-import com.nextcloud.talk.data.user.model.User;
-import com.nextcloud.talk.databinding.CallNotificationActivityBinding;
-import com.nextcloud.talk.events.CallNotificationClick;
-import com.nextcloud.talk.models.json.conversations.Conversation;
-import com.nextcloud.talk.models.json.conversations.RoomOverall;
-import com.nextcloud.talk.models.json.participants.Participant;
-import com.nextcloud.talk.models.json.participants.ParticipantsOverall;
-import com.nextcloud.talk.utils.ApiUtils;
-import com.nextcloud.talk.utils.DisplayUtils;
-import com.nextcloud.talk.utils.DoNotDisturbUtils;
-import com.nextcloud.talk.utils.NotificationUtils;
-import com.nextcloud.talk.utils.ParticipantPermissions;
-import com.nextcloud.talk.utils.bundle.BundleKeys;
-import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew;
-import com.nextcloud.talk.utils.preferences.AppPreferences;
-
-import org.greenrobot.eventbus.EventBus;
-import org.parceler.Parcels;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
-import javax.inject.Inject;
-
-import androidx.annotation.Nullable;
-import androidx.annotation.RequiresApi;
-import autodagger.AutoInjector;
-import io.reactivex.Observable;
-import io.reactivex.Observer;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.annotations.NonNull;
-import io.reactivex.disposables.Disposable;
-import io.reactivex.schedulers.Schedulers;
-import okhttp3.Cache;
-
-@SuppressLint("LongLogTag")
-@AutoInjector(NextcloudTalkApplication.class)
-public class CallNotificationActivity extends CallBaseActivity {
-
-    public static final String TAG = "CallNotificationActivity";
-
-    @Inject
-    NcApi ncApi;
-
-    @Inject
-    AppPreferences appPreferences;
-
-    @Inject
-    Cache cache;
-
-    @Inject
-    EventBus eventBus;
-
-    @Inject
-    Context context;
-
-    private List<Disposable> disposablesList = new ArrayList<>();
-    private Bundle originalBundle;
-    private String roomId;
-    private User userBeingCalled;
-    private String credentials;
-    private Conversation currentConversation;
-    private MediaPlayer mediaPlayer;
-    private boolean leavingScreen = false;
-    private Handler handler;
-    private CallNotificationActivityBinding binding;
-
-    @Override
-    public void onCreate(Bundle savedInstanceState) {
-        Log.d(TAG, "onCreate");
-        super.onCreate(savedInstanceState);
-
-        NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this);
-
-        binding = CallNotificationActivityBinding.inflate(getLayoutInflater());
-        setContentView(binding.getRoot());
-
-        hideNavigationIfNoPipAvailable();
-
-        eventBus.post(new CallNotificationClick());
-
-        Bundle extras = getIntent().getExtras();
-        this.roomId = extras.getString(BundleKeys.KEY_ROOM_ID, "");
-        this.currentConversation = Parcels.unwrap(extras.getParcelable(BundleKeys.KEY_ROOM));
-        this.userBeingCalled = extras.getParcelable(BundleKeys.KEY_USER_ENTITY);
-
-        this.originalBundle = extras;
-        credentials = ApiUtils.getCredentials(userBeingCalled.getUsername(), userBeingCalled.getToken());
-
-        setCallDescriptionText();
-
-        if (currentConversation == null) {
-            handleFromNotification();
-        } else {
-            setUpAfterConversationIsKnown();
-        }
-
-        if (DoNotDisturbUtils.INSTANCE.shouldPlaySound()) {
-            playRingtoneSound();
-        }
-
-        initClickListeners();
-    }
-
-    @Override
-    public void onStart() {
-        super.onStart();
-
-        if (handler == null) {
-            handler = new Handler();
-
-            try {
-                cache.evictAll();
-            } catch (IOException e) {
-                Log.e(TAG, "Failed to evict cache");
-            }
-        }
-    }
-
-    private void initClickListeners() {
-        binding.callAnswerVoiceOnlyView.setOnClickListener(l -> {
-            Log.d(TAG, "accept call (voice only)");
-            originalBundle.putBoolean(BundleKeys.KEY_CALL_VOICE_ONLY, true);
-            proceedToCall();
-        });
-
-        binding.callAnswerCameraView.setOnClickListener(l -> {
-            Log.d(TAG, "accept call (with video)");
-            originalBundle.putBoolean(BundleKeys.KEY_CALL_VOICE_ONLY, false);
-            proceedToCall();
-        });
-
-        binding.hangupButton.setOnClickListener(l -> hangup());
-    }
-
-    private void setCallDescriptionText() {
-        String callDescriptionWithoutTypeInfo =
-            String.format(
-                getResources().getString(R.string.nc_call_unknown),
-                getResources().getString(R.string.nc_app_product_name));
-
-        binding.incomingCallVoiceOrVideoTextView.setText(callDescriptionWithoutTypeInfo);
-    }
-
-    private void showAnswerControls() {
-        binding.callAnswerCameraView.setVisibility(View.VISIBLE);
-        binding.callAnswerVoiceOnlyView.setVisibility(View.VISIBLE);
-    }
-
-    private void hangup() {
-        leavingScreen = true;
-        finish();
-    }
-
-    private void proceedToCall() {
-        originalBundle.putString(BundleKeys.KEY_ROOM_TOKEN, currentConversation.getToken());
-        originalBundle.putString(BundleKeys.KEY_CONVERSATION_NAME, currentConversation.getDisplayName());
-
-        ParticipantPermissions participantPermission = new ParticipantPermissions(userBeingCalled, currentConversation);
-        originalBundle.putBoolean(
-            BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_AUDIO,
-            participantPermission.canPublishAudio());
-        originalBundle.putBoolean(
-            BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO,
-            participantPermission.canPublishVideo());
-
-        Intent intent = new Intent(this, CallActivity.class);
-        intent.putExtras(originalBundle);
-        startActivity(intent);
-    }
-
-    private void checkIfAnyParticipantsRemainInRoom() {
-        int apiVersion = ApiUtils.getCallApiVersion(userBeingCalled, new int[]{ApiUtils.APIv4, 1});
-
-        ncApi.getPeersForCall(
-            credentials,
-            ApiUtils.getUrlForCall(
-                apiVersion,
-                userBeingCalled.getBaseUrl(),
-                currentConversation.getToken()))
-            .subscribeOn(Schedulers.io())
-            .repeatWhen(completed -> completed.zipWith(Observable.range(1, 12), (n, i) -> i)
-                .flatMap(retryCount -> Observable.timer(5, TimeUnit.SECONDS))
-                .takeWhile(observable -> !leavingScreen))
-            .subscribe(new Observer<ParticipantsOverall>() {
-                @Override
-                public void onSubscribe(@NonNull Disposable d) {
-                    disposablesList.add(d);
-                }
-
-                @Override
-                public void onNext(@NonNull ParticipantsOverall participantsOverall) {
-                    boolean hasParticipantsInCall = false;
-                    boolean inCallOnDifferentDevice = false;
-                    List<Participant> participantList = participantsOverall.getOcs().getData();
-                    hasParticipantsInCall = participantList.size() > 0;
-
-                    if (hasParticipantsInCall) {
-                        for (Participant participant : participantList) {
-                            if (participant.getCalculatedActorType() == Participant.ActorType.USERS &&
-                                participant.getCalculatedActorId().equals(userBeingCalled.getUserId())) {
-                                inCallOnDifferentDevice = true;
-                                break;
-                            }
-                        }
-                    }
-
-                    if (!hasParticipantsInCall || inCallOnDifferentDevice) {
-                        runOnUiThread(() -> hangup());
-                    }
-                }
-
-                @Override
-                public void onError(@NonNull Throwable e) {
-                    Log.e(TAG, "error while getPeersForCall", e);
-                }
-
-                @Override
-                public void onComplete() {
-                    runOnUiThread(() -> hangup());
-                }
-            });
-
-    }
-
-    private void handleFromNotification() {
-        int apiVersion = ApiUtils.getConversationApiVersion(userBeingCalled, new int[]{ApiUtils.APIv4,
-            ApiUtils.APIv3, 1});
-
-        ncApi.getRoom(credentials, ApiUtils.getUrlForRoom(apiVersion, userBeingCalled.getBaseUrl(), roomId))
-            .subscribeOn(Schedulers.io())
-            .retry(3)
-            .observeOn(AndroidSchedulers.mainThread())
-            .subscribe(new Observer<RoomOverall>() {
-                @Override
-                public void onSubscribe(@NonNull Disposable d) {
-                    disposablesList.add(d);
-                }
-
-                @Override
-                public void onNext(@NonNull RoomOverall roomOverall) {
-                    currentConversation = roomOverall.getOcs().getData();
-                    setUpAfterConversationIsKnown();
-
-                    if (apiVersion >= 3) {
-                        boolean hasCallFlags =
-                            CapabilitiesUtilNew.hasSpreedFeatureCapability(userBeingCalled,
-                                                                           "conversation-call-flags");
-                        if (hasCallFlags) {
-                            if (isInCallWithVideo(currentConversation.getCallFlag())) {
-                                binding.incomingCallVoiceOrVideoTextView.setText(
-                                    String.format(getResources().getString(R.string.nc_call_video),
-                                                  getResources().getString(R.string.nc_app_product_name)));
-                            } else {
-                                binding.incomingCallVoiceOrVideoTextView.setText(
-                                    String.format(getResources().getString(R.string.nc_call_voice),
-                                                  getResources().getString(R.string.nc_app_product_name)));
-                            }
-                        }
-                    }
-                }
-
-                @Override
-                public void onError(@NonNull Throwable e) {
-                    Log.e(TAG, e.getMessage(), e);
-                }
-
-                @Override
-                public void onComplete() {
-                    // unused atm
-                }
-            });
-    }
-
-    private boolean isInCallWithVideo(int callFlag) {
-        return (callFlag >= Participant.InCallFlags.IN_CALL + Participant.InCallFlags.WITH_VIDEO);
-    }
-
-    private void setUpAfterConversationIsKnown() {
-        binding.conversationNameTextView.setText(currentConversation.getDisplayName());
-
-        if(currentConversation.getType() == Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL){
-            setAvatarForOneToOneCall();
-        } else {
-            binding.avatarImageView.setImageResource(R.drawable.ic_circular_group);
-        }
-
-        checkIfAnyParticipantsRemainInRoom();
-        showAnswerControls();
-    }
-
-    private void setAvatarForOneToOneCall() {
-        ImageRequest imageRequest =
-            DisplayUtils.getImageRequestForUrl(
-                ApiUtils.getUrlForAvatar(userBeingCalled.getBaseUrl(),
-                                         currentConversation.getName(),
-                                         true));
-
-        ImagePipeline imagePipeline = Fresco.getImagePipeline();
-        DataSource<CloseableReference<CloseableImage>> dataSource = imagePipeline.fetchDecodedImage(imageRequest, null);
-
-        dataSource.subscribe(new BaseBitmapDataSubscriber() {
-            @Override
-            protected void onNewResultImpl(@Nullable Bitmap bitmap) {
-                binding.avatarImageView.getHierarchy().setImage(
-                    new BitmapDrawable(getResources(), bitmap),
-                    100,
-                    true);
-            }
-
-            @Override
-            protected void onFailureImpl(DataSource<CloseableReference<CloseableImage>> dataSource) {
-                Log.e(TAG, "failed to load avatar");
-            }
-        }, UiThreadImmediateExecutorService.getInstance());
-    }
-
-    private void endMediaNotifications() {
-        if (mediaPlayer != null) {
-            if (mediaPlayer.isPlaying()) {
-                mediaPlayer.stop();
-            }
-
-            mediaPlayer.release();
-            mediaPlayer = null;
-        }
-    }
-
-    @Override
-    public void onDestroy() {
-        leavingScreen = true;
-        if (handler != null) {
-            handler.removeCallbacksAndMessages(null);
-            handler = null;
-        }
-        dispose();
-        endMediaNotifications();
-        super.onDestroy();
-    }
-
-    private void dispose() {
-        if (disposablesList != null) {
-            for (Disposable disposable : disposablesList) {
-                if (!disposable.isDisposed()) {
-                    disposable.dispose();
-                }
-            }
-        }
-    }
-
-    private void playRingtoneSound() {
-        Uri ringtoneUri = NotificationUtils.INSTANCE.getCallRingtoneUri(getApplicationContext(), appPreferences);
-        if (ringtoneUri != null) {
-            mediaPlayer = new MediaPlayer();
-            try {
-                mediaPlayer.setDataSource(this, ringtoneUri);
-
-                mediaPlayer.setLooping(true);
-                AudioAttributes audioAttributes = new AudioAttributes
-                    .Builder()
-                    .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
-                    .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
-                    .build();
-                mediaPlayer.setAudioAttributes(audioAttributes);
-
-                mediaPlayer.setOnPreparedListener(mp -> mediaPlayer.start());
-
-                mediaPlayer.prepareAsync();
-            } catch (IOException e) {
-                Log.e(TAG, "Failed to set data source");
-            }
-        }
-    }
-
-    @RequiresApi(api = Build.VERSION_CODES.O)
-    @Override
-    public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) {
-        super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
-        isInPipMode = isInPictureInPictureMode;
-        if (isInPictureInPictureMode) {
-            updateUiForPipMode();
-        } else {
-            updateUiForNormalMode();
-        }
-    }
-
-    public void updateUiForPipMode() {
-        binding.callAnswerButtons.setVisibility(View.INVISIBLE);
-        binding.incomingCallRelativeLayout.setVisibility(View.INVISIBLE);
-    }
-
-    public void updateUiForNormalMode() {
-        binding.callAnswerButtons.setVisibility(View.VISIBLE);
-        binding.incomingCallRelativeLayout.setVisibility(View.VISIBLE);
-    }
-
-    @Override
-    void suppressFitsSystemWindows() {
-        binding.controllerCallNotificationLayout.setFitsSystemWindows(false);
-    }
-}

+ 476 - 0
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 <dev@mhibbe.de>
+ * Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.talk.activities
+
+import android.annotation.SuppressLint
+import android.app.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.res.Configuration
+import android.graphics.Bitmap
+import android.graphics.drawable.BitmapDrawable
+import android.media.AudioAttributes
+import android.media.MediaPlayer
+import android.os.Build
+import android.os.Bundle
+import android.os.Handler
+import android.os.SystemClock
+import android.util.Log
+import android.view.View
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationCompat
+import autodagger.AutoInjector
+import com.facebook.common.executors.UiThreadImmediateExecutorService
+import com.facebook.common.references.CloseableReference
+import com.facebook.datasource.DataSource
+import com.facebook.drawee.backends.pipeline.Fresco
+import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber
+import com.facebook.imagepipeline.image.CloseableImage
+import com.nextcloud.talk.R
+import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.databinding.CallNotificationActivityBinding
+import com.nextcloud.talk.models.json.conversations.Conversation
+import com.nextcloud.talk.models.json.conversations.RoomOverall
+import com.nextcloud.talk.models.json.participants.Participant
+import com.nextcloud.talk.models.json.participants.ParticipantsOverall
+import com.nextcloud.talk.utils.ApiUtils
+import com.nextcloud.talk.utils.DisplayUtils
+import com.nextcloud.talk.utils.DoNotDisturbUtils.shouldPlaySound
+import com.nextcloud.talk.utils.NotificationUtils
+import com.nextcloud.talk.utils.NotificationUtils.getCallRingtoneUri
+import com.nextcloud.talk.utils.ParticipantPermissions
+import com.nextcloud.talk.utils.bundle.BundleKeys
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY
+import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew.hasSpreedFeatureCapability
+import io.reactivex.Observable
+import io.reactivex.Observer
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.disposables.Disposable
+import io.reactivex.schedulers.Schedulers
+import okhttp3.Cache
+import org.parceler.Parcels
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+
+@SuppressLint("LongLogTag")
+@AutoInjector(NextcloudTalkApplication::class)
+class CallNotificationActivity : CallBaseActivity() {
+    @JvmField
+    @Inject
+    var ncApi: NcApi? = null
+
+    @JvmField
+    @Inject
+    var cache: Cache? = null
+
+    private val disposablesList: MutableList<Disposable> = ArrayList()
+    private var originalBundle: Bundle? = null
+    private var roomToken: String? = null
+    private var userBeingCalled: User? = null
+    private var credentials: String? = null
+    private var currentConversation: Conversation? = null
+    private var mediaPlayer: MediaPlayer? = null
+    private var leavingScreen = false
+    private var handler: Handler? = null
+    private var binding: CallNotificationActivityBinding? = null
+    override fun onCreate(savedInstanceState: Bundle?) {
+        Log.d(TAG, "onCreate")
+        super.onCreate(savedInstanceState)
+        sharedApplication!!.componentApplication.inject(this)
+        binding = CallNotificationActivityBinding.inflate(layoutInflater)
+        setContentView(binding!!.root)
+        hideNavigationIfNoPipAvailable()
+        val extras = intent.extras
+        roomToken = extras!!.getString(KEY_ROOM_TOKEN, "")
+        currentConversation = Parcels.unwrap(extras.getParcelable(KEY_ROOM))
+        userBeingCalled = extras.getParcelable(KEY_USER_ENTITY)
+        originalBundle = extras
+        credentials = ApiUtils.getCredentials(userBeingCalled!!.username, userBeingCalled!!.token)
+        setCallDescriptionText()
+        if (currentConversation == null) {
+            handleFromNotification()
+        } else {
+            setUpAfterConversationIsKnown()
+        }
+        if (shouldPlaySound()) {
+            playRingtoneSound()
+        }
+        initClickListeners()
+    }
+
+    override fun onStart() {
+        super.onStart()
+        if (handler == null) {
+            handler = Handler()
+            try {
+                cache!!.evictAll()
+            } catch (e: IOException) {
+                Log.e(TAG, "Failed to evict cache")
+            }
+        }
+    }
+
+    private fun initClickListeners() {
+        binding!!.callAnswerVoiceOnlyView.setOnClickListener {
+            Log.d(TAG, "accept call (voice only)")
+            originalBundle!!.putBoolean(KEY_CALL_VOICE_ONLY, true)
+            proceedToCall()
+        }
+        binding!!.callAnswerCameraView.setOnClickListener {
+            Log.d(TAG, "accept call (with video)")
+            originalBundle!!.putBoolean(KEY_CALL_VOICE_ONLY, false)
+            proceedToCall()
+        }
+        binding!!.hangupButton.setOnClickListener { hangup() }
+    }
+
+    private fun setCallDescriptionText() {
+        val callDescriptionWithoutTypeInfo = String.format(
+            resources.getString(R.string.nc_call_unknown),
+            resources.getString(R.string.nc_app_product_name)
+        )
+        binding!!.incomingCallVoiceOrVideoTextView.text = callDescriptionWithoutTypeInfo
+    }
+
+    private fun showAnswerControls() {
+        binding!!.callAnswerCameraView.visibility = View.VISIBLE
+        binding!!.callAnswerVoiceOnlyView.visibility = View.VISIBLE
+    }
+
+    private fun hangup() {
+        leavingScreen = true
+        dispose()
+        endMediaNotifications()
+        finish()
+    }
+
+    private fun proceedToCall() {
+        originalBundle!!.putString(KEY_ROOM_TOKEN, currentConversation!!.token)
+        originalBundle!!.putString(KEY_CONVERSATION_NAME, currentConversation!!.displayName)
+
+        val participantPermission = ParticipantPermissions(
+            userBeingCalled!!,
+            currentConversation!!
+        )
+        originalBundle!!.putBoolean(
+            BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_AUDIO,
+            participantPermission.canPublishAudio()
+        )
+        originalBundle!!.putBoolean(
+            BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO,
+            participantPermission.canPublishVideo()
+        )
+
+        val intent = Intent(this, CallActivity::class.java)
+        intent.putExtras(originalBundle!!)
+        startActivity(intent)
+    }
+
+    private fun checkIfAnyParticipantsRemainInRoom() {
+        val apiVersion = ApiUtils.getCallApiVersion(userBeingCalled, intArrayOf(ApiUtils.APIv4, 1))
+        ncApi!!.getPeersForCall(
+            credentials,
+            ApiUtils.getUrlForCall(
+                apiVersion,
+                userBeingCalled!!.baseUrl,
+                currentConversation!!.token
+            )
+        )
+            .subscribeOn(Schedulers.io())
+            .repeatWhen { completed: Observable<Any?> ->
+                completed.zipWith(Observable.range(TIMER_START, TIMER_COUNT)) { _: Any?, i: Int? -> i!! }
+                    .flatMap { Observable.timer(TIMER_DELAY, TimeUnit.SECONDS) }
+                    .takeWhile { !leavingScreen }
+            }
+            .subscribe(object : Observer<ParticipantsOverall> {
+                override fun onSubscribe(d: Disposable) {
+                    disposablesList.add(d)
+                }
+
+                override fun onNext(participantsOverall: ParticipantsOverall) {
+                    val hasParticipantsInCall: Boolean
+                    var inCallOnDifferentDevice = false
+                    val participantList = participantsOverall.ocs!!.data
+                    hasParticipantsInCall = participantList!!.isNotEmpty()
+                    if (hasParticipantsInCall) {
+                        for (participant in participantList) {
+                            if (participant.calculatedActorType === Participant.ActorType.USERS &&
+                                participant.calculatedActorId == userBeingCalled!!.userId
+                            ) {
+                                inCallOnDifferentDevice = true
+                                break
+                            }
+                        }
+                    }
+                    if (inCallOnDifferentDevice) {
+                        runOnUiThread { hangup() }
+                    }
+                    if (!hasParticipantsInCall) {
+                        showMissedCallNotification()
+                        runOnUiThread { hangup() }
+                    }
+                }
+
+                override fun onError(e: Throwable) {
+                    Log.e(TAG, "error while getPeersForCall", e)
+                }
+
+                override fun onComplete() {
+                    showMissedCallNotification()
+                    runOnUiThread { hangup() }
+                }
+            })
+    }
+
+    private fun showMissedCallNotification() {
+        val mNotifyManager: NotificationManager?
+        val mBuilder: NotificationCompat.Builder?
+
+        mNotifyManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+        mBuilder = NotificationCompat.Builder(
+            context,
+            NotificationUtils.NotificationChannels
+                .NOTIFICATION_CHANNEL_MESSAGES_V4.name
+        )
+
+        val notification: Notification = mBuilder
+            .setContentTitle(
+                String.format(resources.getString(R.string.nc_missed_call), currentConversation!!.displayName)
+            )
+            .setSmallIcon(R.drawable.ic_baseline_phone_missed_24)
+            .setOngoing(false)
+            .setAutoCancel(true)
+            .setPriority(NotificationCompat.PRIORITY_LOW)
+            .setContentIntent(getIntentToOpenConversation())
+            .build()
+
+        val notificationId: Int = SystemClock.uptimeMillis().toInt()
+        mNotifyManager.notify(notificationId, notification)
+    }
+
+    private fun getIntentToOpenConversation(): PendingIntent? {
+        val bundle = Bundle()
+        val intent = Intent(context, MainActivity::class.java)
+        intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
+
+        bundle.putString(KEY_ROOM_TOKEN, currentConversation?.token)
+        bundle.putParcelable(KEY_USER_ENTITY, userBeingCalled)
+        bundle.putBoolean(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL, false)
+
+        intent.putExtras(bundle)
+
+        val requestCode = System.currentTimeMillis().toInt()
+        val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            PendingIntent.FLAG_MUTABLE
+        } else {
+            0
+        }
+        return PendingIntent.getActivity(context, requestCode, intent, intentFlag)
+    }
+
+    @Suppress("MagicNumber")
+    private fun handleFromNotification() {
+        val apiVersion = ApiUtils.getConversationApiVersion(
+            userBeingCalled,
+            intArrayOf(
+                ApiUtils.APIv4,
+                ApiUtils.APIv3, 1
+            )
+        )
+        ncApi!!.getRoom(credentials, ApiUtils.getUrlForRoom(apiVersion, userBeingCalled!!.baseUrl, roomToken))
+            .subscribeOn(Schedulers.io())
+            .retry(GET_ROOM_RETRY_COUNT)
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribe(object : Observer<RoomOverall> {
+                override fun onSubscribe(d: Disposable) {
+                    disposablesList.add(d)
+                }
+
+                override fun onNext(roomOverall: RoomOverall) {
+                    currentConversation = roomOverall.ocs!!.data
+                    setUpAfterConversationIsKnown()
+                    if (apiVersion >= 3) {
+                        val hasCallFlags = hasSpreedFeatureCapability(
+                            userBeingCalled,
+                            "conversation-call-flags"
+                        )
+                        if (hasCallFlags) {
+                            if (isInCallWithVideo(currentConversation!!.callFlag)) {
+                                binding!!.incomingCallVoiceOrVideoTextView.text = String.format(
+                                    resources.getString(R.string.nc_call_video),
+                                    resources.getString(R.string.nc_app_product_name)
+                                )
+                            } else {
+                                binding!!.incomingCallVoiceOrVideoTextView.text = String.format(
+                                    resources.getString(R.string.nc_call_voice),
+                                    resources.getString(R.string.nc_app_product_name)
+                                )
+                            }
+                        }
+                    }
+                }
+
+                override fun onError(e: Throwable) {
+                    Log.e(TAG, e.message, e)
+                }
+
+                override fun onComplete() {
+                    // unused atm
+                }
+            })
+    }
+
+    private fun isInCallWithVideo(callFlag: Int): Boolean {
+        return callFlag >= Participant.InCallFlags.IN_CALL + Participant.InCallFlags.WITH_VIDEO
+    }
+
+    private fun setUpAfterConversationIsKnown() {
+        binding!!.conversationNameTextView.text = currentConversation!!.displayName
+        if (currentConversation!!.type === Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
+            setAvatarForOneToOneCall()
+        } else {
+            binding!!.avatarImageView.setImageResource(R.drawable.ic_circular_group)
+        }
+        checkIfAnyParticipantsRemainInRoom()
+        showAnswerControls()
+    }
+
+    @Suppress("MagicNumber")
+    private fun setAvatarForOneToOneCall() {
+        val imageRequest = DisplayUtils.getImageRequestForUrl(
+            ApiUtils.getUrlForAvatar(
+                userBeingCalled!!.baseUrl,
+                currentConversation!!.name,
+                true
+            )
+        )
+        val imagePipeline = Fresco.getImagePipeline()
+        val dataSource = imagePipeline.fetchDecodedImage(imageRequest, null)
+        dataSource.subscribe(
+            object : BaseBitmapDataSubscriber() {
+                override fun onNewResultImpl(bitmap: Bitmap?) {
+                    binding!!.avatarImageView.hierarchy.setImage(
+                        BitmapDrawable(resources, bitmap), 100f,
+                        true
+                    )
+                }
+
+                override fun onFailureImpl(dataSource: DataSource<CloseableReference<CloseableImage?>>) {
+                    Log.e(TAG, "failed to load avatar")
+                }
+            },
+            UiThreadImmediateExecutorService.getInstance()
+        )
+    }
+
+    private fun endMediaNotifications() {
+        if (mediaPlayer != null) {
+            if (mediaPlayer!!.isPlaying) {
+                mediaPlayer!!.stop()
+            }
+            mediaPlayer!!.release()
+            mediaPlayer = null
+        }
+    }
+
+    public override fun onDestroy() {
+        leavingScreen = true
+        if (handler != null) {
+            handler!!.removeCallbacksAndMessages(null)
+            handler = null
+        }
+        dispose()
+        endMediaNotifications()
+        super.onDestroy()
+    }
+
+    private fun dispose() {
+        for (disposable in disposablesList) {
+            if (!disposable.isDisposed) {
+                disposable.dispose()
+            }
+        }
+    }
+
+    private fun playRingtoneSound() {
+        val ringtoneUri = getCallRingtoneUri(applicationContext, appPreferences)
+        if (ringtoneUri != null) {
+            mediaPlayer = MediaPlayer()
+            try {
+                mediaPlayer!!.setDataSource(this, ringtoneUri)
+                mediaPlayer!!.isLooping = true
+                val audioAttributes = AudioAttributes.Builder()
+                    .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+                    .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
+                    .build()
+                mediaPlayer!!.setAudioAttributes(audioAttributes)
+                mediaPlayer!!.setOnPreparedListener { mediaPlayer!!.start() }
+                mediaPlayer!!.prepareAsync()
+            } catch (e: IOException) {
+                Log.e(TAG, "Failed to set data source")
+            }
+        }
+    }
+
+    @RequiresApi(api = Build.VERSION_CODES.O)
+    override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
+        super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
+        isInPipMode = isInPictureInPictureMode
+        if (isInPictureInPictureMode) {
+            updateUiForPipMode()
+        } else {
+            updateUiForNormalMode()
+        }
+    }
+
+    public override fun updateUiForPipMode() {
+        binding!!.callAnswerButtons.visibility = View.INVISIBLE
+        binding!!.incomingCallRelativeLayout.visibility = View.INVISIBLE
+    }
+
+    public override fun updateUiForNormalMode() {
+        binding!!.callAnswerButtons.visibility = View.VISIBLE
+        binding!!.incomingCallRelativeLayout.visibility = View.VISIBLE
+    }
+
+    public override fun suppressFitsSystemWindows() {
+        binding!!.controllerCallNotificationLayout.fitsSystemWindows = false
+    }
+
+    companion object {
+        const val TAG = "CallNotificationActivity"
+        const val TIMER_START = 1
+        const val TIMER_COUNT = 12
+        const val TIMER_DELAY: Long = 5
+        const val GET_ROOM_RETRY_COUNT: Long = 3
+    }
+}

+ 0 - 23
app/src/main/java/com/nextcloud/talk/events/CallNotificationClick.kt

@@ -1,23 +0,0 @@
-/*
- * Nextcloud Talk application
- *
- * @author Mario Danic
- * Copyright (C) 2017-2019 Mario Danic <mario@lovelyhq.com>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package com.nextcloud.talk.events
-
-class CallNotificationClick

+ 0 - 695
app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.java

@@ -1,695 +0,0 @@
-/*
- * Nextcloud Talk application
- *
- * @author Andy Scherzinger
- * @author Mario Danic
- * Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
- * Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package com.nextcloud.talk.jobs;
-
-import android.app.Notification;
-import android.app.PendingIntent;
-import android.content.Context;
-import android.content.Intent;
-import android.graphics.Bitmap;
-import android.graphics.BitmapFactory;
-import android.media.AudioAttributes;
-import android.media.MediaPlayer;
-import android.net.Uri;
-import android.os.Build;
-import android.os.Bundle;
-import android.service.notification.StatusBarNotification;
-import android.text.TextUtils;
-import android.util.Base64;
-import android.util.Log;
-
-import com.bluelinelabs.logansquare.LoganSquare;
-import com.nextcloud.talk.R;
-import com.nextcloud.talk.activities.CallActivity;
-import com.nextcloud.talk.activities.MainActivity;
-import com.nextcloud.talk.api.NcApi;
-import com.nextcloud.talk.application.NextcloudTalkApplication;
-import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager;
-import com.nextcloud.talk.data.user.model.User;
-import com.nextcloud.talk.models.SignatureVerification;
-import com.nextcloud.talk.models.json.chat.ChatUtils;
-import com.nextcloud.talk.models.json.conversations.Conversation;
-import com.nextcloud.talk.models.json.conversations.RoomOverall;
-import com.nextcloud.talk.models.json.notifications.NotificationOverall;
-import com.nextcloud.talk.models.json.push.DecryptedPushMessage;
-import com.nextcloud.talk.models.json.push.NotificationUser;
-import com.nextcloud.talk.receivers.DirectReplyReceiver;
-import com.nextcloud.talk.receivers.MarkAsReadReceiver;
-import com.nextcloud.talk.utils.ApiUtils;
-import com.nextcloud.talk.utils.DoNotDisturbUtils;
-import com.nextcloud.talk.utils.NotificationUtils;
-import com.nextcloud.talk.utils.PushUtils;
-import com.nextcloud.talk.utils.UserIdUtils;
-import com.nextcloud.talk.utils.bundle.BundleKeys;
-import com.nextcloud.talk.utils.preferences.AppPreferences;
-import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder;
-
-import org.parceler.Parcels;
-
-import java.io.IOException;
-import java.net.CookieManager;
-import java.security.InvalidKeyException;
-import java.security.NoSuchAlgorithmException;
-import java.security.PrivateKey;
-import java.util.HashMap;
-import java.util.Objects;
-import java.util.zip.CRC32;
-
-import javax.crypto.Cipher;
-import javax.crypto.NoSuchPaddingException;
-import javax.inject.Inject;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RequiresApi;
-import androidx.core.app.NotificationCompat;
-import androidx.core.app.NotificationCompat.MessagingStyle;
-import androidx.core.app.NotificationManagerCompat;
-import androidx.core.app.Person;
-import androidx.core.app.RemoteInput;
-import androidx.emoji.text.EmojiCompat;
-import androidx.work.Data;
-import androidx.work.Worker;
-import androidx.work.WorkerParameters;
-import autodagger.AutoInjector;
-import io.reactivex.Maybe;
-import io.reactivex.Observer;
-import io.reactivex.disposables.Disposable;
-import okhttp3.JavaNetCookieJar;
-import okhttp3.OkHttpClient;
-import retrofit2.Retrofit;
-
-@AutoInjector(NextcloudTalkApplication.class)
-public class NotificationWorker extends Worker {
-    public static final String TAG = "NotificationWorker";
-    private static final String CHAT = "chat";
-    private static final String ROOM = "room";
-
-    @Inject
-    AppPreferences appPreferences;
-
-    @Inject
-    ArbitraryStorageManager arbitraryStorageManager;
-
-    @Inject
-    Retrofit retrofit;
-
-    @Inject
-    OkHttpClient okHttpClient;
-
-    private NcApi ncApi;
-
-    private DecryptedPushMessage decryptedPushMessage;
-    private Context context;
-    private SignatureVerification signatureVerification;
-    private String conversationType = "one2one";
-
-    private String credentials;
-    private boolean muteCall = false;
-    private boolean importantConversation = false;
-
-    public NotificationWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
-        super(context, workerParams);
-    }
-
-    private void showNotificationForCallWithNoPing(Intent intent) {
-        User user = signatureVerification.getUser();
-
-        importantConversation = arbitraryStorageManager.getStorageSetting(
-                UserIdUtils.INSTANCE.getIdForUser(user),
-                "important_conversation",
-                intent.getExtras().getString(BundleKeys.KEY_ROOM_TOKEN))
-            .map(arbitraryStorage -> {
-                if (arbitraryStorage != null && arbitraryStorage.getValue() != null) {
-                    return Boolean.parseBoolean(arbitraryStorage.getValue());
-                } else {
-                    return importantConversation;
-                }
-            })
-            .switchIfEmpty(Maybe.just(importantConversation))
-            .blockingGet();
-
-        Log.e(TAG, "showNotificationForCallWithNoPing: importantConversation: " + importantConversation);
-
-        int apiVersion = ApiUtils.getConversationApiVersion(user, new int[] {ApiUtils.APIv4, 1});
-
-        ncApi.getRoom(credentials, ApiUtils.getUrlForRoom(apiVersion, user.getBaseUrl(),
-                intent.getExtras().getString(BundleKeys.KEY_ROOM_TOKEN)))
-                .blockingSubscribe(new Observer<RoomOverall>() {
-                    @Override
-                    public void onSubscribe(Disposable d) {
-                        // unused atm
-                    }
-
-                    @Override
-                    public void onNext(RoomOverall roomOverall) {
-                        Conversation conversation = roomOverall.getOcs().getData();
-
-                        intent.putExtra(BundleKeys.KEY_ROOM, Parcels.wrap(conversation));
-                        if (conversation.getType().equals(Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) ||
-                                (!TextUtils.isEmpty(conversation.getObjectType()) && "share:password".equals
-                                        (conversation.getObjectType()))) {
-                            context.startActivity(intent);
-                        } else {
-                            if (conversation.getType().equals(Conversation.ConversationType.ROOM_GROUP_CALL)) {
-                                conversationType = "group";
-                            } else {
-                                conversationType = "public";
-                            }
-                            if (decryptedPushMessage.getNotificationId() != Long.MIN_VALUE) {
-                                showNotificationWithObjectData(intent);
-                            } else {
-                                showNotification(intent);
-                            }
-                        }
-
-                        muteCall = conversation.getNotificationCalls() != 1;
-                    }
-
-                    @Override
-                    public void onError(Throwable e) {
-                        // unused atm
-                    }
-
-                    @Override
-                    public void onComplete() {
-                        // unused atm
-                    }
-                });
-    }
-
-    private void showNotificationWithObjectData(Intent intent) {
-        User user = signatureVerification.getUser();
-        ncApi.getNotification(credentials, ApiUtils.getUrlForNotificationWithId(user.getBaseUrl(),
-                Long.toString(decryptedPushMessage.getNotificationId())))
-                .blockingSubscribe(new Observer<NotificationOverall>() {
-                    @Override
-                    public void onSubscribe(Disposable d) {
-                        // unused atm
-                    }
-
-                    @Override
-                    public void onNext(NotificationOverall notificationOverall) {
-                        com.nextcloud.talk.models.json.notifications.Notification notification =
-                                notificationOverall.getOcs().getNotification();
-
-                        if (notification.getMessageRichParameters() != null &&
-                                notification.getMessageRichParameters().size() > 0) {
-                            decryptedPushMessage.setText(ChatUtils.Companion.getParsedMessage(
-                                notification.getMessageRich(),
-                                notification.getMessageRichParameters()));
-                        } else {
-                            decryptedPushMessage.setText(notification.getMessage());
-                        }
-
-                        HashMap<String, HashMap<String, String>> subjectRichParameters = notification
-                                .getSubjectRichParameters();
-
-                        decryptedPushMessage.setTimestamp(notification.getDatetime().getMillis());
-
-                        if (subjectRichParameters != null && subjectRichParameters.size() > 0) {
-                            HashMap<String, String> callHashMap = subjectRichParameters.get("call");
-                            HashMap<String, String> userHashMap = subjectRichParameters.get("user");
-                            HashMap<String, String> guestHashMap = subjectRichParameters.get("guest");
-
-                            if (callHashMap != null && callHashMap.size() > 0 && callHashMap.containsKey("name")) {
-                                if (subjectRichParameters.containsKey("reaction")) {
-                                    decryptedPushMessage.setSubject("");
-                                    decryptedPushMessage.setText(notification.getSubject());
-                                } else if (Objects.equals(notification.getObjectType(), "chat")) {
-                                    decryptedPushMessage.setSubject(Objects.requireNonNull(callHashMap.get("name")));
-                                } else {
-                                    decryptedPushMessage.setSubject(Objects.requireNonNull(notification.getSubject()));
-                                }
-
-                                if (callHashMap.containsKey("call-type")) {
-                                    conversationType = callHashMap.get("call-type");
-                                }
-                            }
-
-                            NotificationUser notificationUser = new NotificationUser();
-                            if (userHashMap != null && !userHashMap.isEmpty()) {
-                                notificationUser.setId(userHashMap.get("id"));
-                                notificationUser.setType(userHashMap.get("type"));
-                                notificationUser.setName(userHashMap.get("name"));
-                                decryptedPushMessage.setNotificationUser(notificationUser);
-                            } else if (guestHashMap != null && !guestHashMap.isEmpty()) {
-                                notificationUser.setId(guestHashMap.get("id"));
-                                notificationUser.setType(guestHashMap.get("type"));
-                                notificationUser.setName(guestHashMap.get("name"));
-                                decryptedPushMessage.setNotificationUser(notificationUser);
-                            }
-                        }
-
-                        decryptedPushMessage.setObjectId(notification.getObjectId());
-
-                        showNotification(intent);
-                    }
-
-                    @Override
-                    public void onError(Throwable e) {
-                        // unused atm
-                    }
-
-                    @Override
-                    public void onComplete() {
-                        // unused atm
-                    }
-                });
-    }
-
-    private void showNotification(Intent intent) {
-        int smallIcon;
-        Bitmap largeIcon;
-        String category;
-        int priority = Notification.PRIORITY_HIGH;
-
-        smallIcon = R.drawable.ic_logo;
-
-        if (CHAT.equals(decryptedPushMessage.getType()) || ROOM.equals(decryptedPushMessage.getType())) {
-            category = Notification.CATEGORY_MESSAGE;
-        } else {
-            category = Notification.CATEGORY_CALL;
-        }
-
-        switch (conversationType) {
-            case "one2one":
-                decryptedPushMessage.setSubject("");
-            case "group":
-                largeIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_people_group_black_24px);
-                break;
-            case "public":
-                largeIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_link_black_24px);
-                break;
-            default:
-                // assuming one2one
-                if (CHAT.equals(decryptedPushMessage.getType()) || ROOM.equals(decryptedPushMessage.getType())) {
-                    largeIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_comment);
-                } else {
-                    largeIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_call_black_24dp);
-                }
-        }
-
-        // Use unique request code to make sure that a new PendingIntent gets created for each notification
-        // See https://github.com/nextcloud/talk-android/issues/2111
-        int requestCode = (int) System.currentTimeMillis();
-        int intentFlag;
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-            intentFlag = PendingIntent.FLAG_MUTABLE;
-        } else {
-            intentFlag = 0;
-        }
-        PendingIntent pendingIntent = PendingIntent.getActivity(context, requestCode, intent, intentFlag);
-
-        Uri uri = Uri.parse(signatureVerification.getUser().getBaseUrl());
-        String baseUrl = uri.getHost();
-
-        NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(context, "1")
-                .setLargeIcon(largeIcon)
-                .setSmallIcon(smallIcon)
-                .setCategory(category)
-                .setPriority(priority)
-                .setSubText(baseUrl)
-                .setWhen(decryptedPushMessage.getTimestamp())
-                .setShowWhen(true)
-                .setContentIntent(pendingIntent)
-                .setAutoCancel(true);
-
-        if (!TextUtils.isEmpty(decryptedPushMessage.getSubject())) {
-            notificationBuilder.setContentTitle(EmojiCompat.get().process(decryptedPushMessage.getSubject()));
-        }
-
-        if (!TextUtils.isEmpty(decryptedPushMessage.getText())) {
-            notificationBuilder.setContentText(EmojiCompat.get().process(decryptedPushMessage.getText()));
-        }
-
-        if (Build.VERSION.SDK_INT >= 23) {
-            // This method should exist since API 21, but some phones don't have it
-            // So as a safeguard, we don't use it until 23
-
-            notificationBuilder.setColor(context.getResources().getColor(R.color.colorPrimary));
-        }
-
-        Bundle notificationInfo = new Bundle();
-        notificationInfo.putLong(BundleKeys.KEY_INTERNAL_USER_ID,
-                                 signatureVerification.getUser().getId());
-        // could be an ID or a TOKEN
-        notificationInfo.putString(BundleKeys.KEY_ROOM_TOKEN,
-                                   decryptedPushMessage.getId());
-        notificationInfo.putLong(BundleKeys.KEY_NOTIFICATION_ID,
-                                 decryptedPushMessage.getNotificationId());
-        notificationBuilder.setExtras(notificationInfo);
-
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-            if (CHAT.equals(decryptedPushMessage.getType()) || ROOM.equals(decryptedPushMessage.getType())) {
-                notificationBuilder.setChannelId(NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name());
-            }
-        } else {
-            // red color for the lights
-            notificationBuilder.setLights(0xFFFF0000, 200, 200);
-        }
-
-        notificationBuilder.setContentIntent(pendingIntent);
-
-        String groupName = signatureVerification.getUser().getId() + "@" + decryptedPushMessage.getId();
-        notificationBuilder.setGroup(Long.toString(calculateCRC32(groupName)));
-
-        StatusBarNotification activeStatusBarNotification =
-                NotificationUtils.INSTANCE.findNotificationForRoom(context,
-                                                                   signatureVerification.getUser(),
-                                                                   decryptedPushMessage.getId());
-
-        // NOTE - systemNotificationId is an internal ID used on the device only.
-        // It is NOT the same as the notification ID used in communication with the server.
-        int systemNotificationId;
-        if (activeStatusBarNotification != null) {
-            systemNotificationId = activeStatusBarNotification.getId();
-        } else {
-            systemNotificationId = (int) calculateCRC32(String.valueOf(System.currentTimeMillis()));
-        }
-
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
-            CHAT.equals(decryptedPushMessage.getType()) &&
-            decryptedPushMessage.getNotificationUser() != null) {
-            prepareChatNotification(notificationBuilder, activeStatusBarNotification, systemNotificationId);
-        }
-
-        sendNotification(systemNotificationId, notificationBuilder.build());
-    }
-
-    private long calculateCRC32(String s) {
-        CRC32 crc32 = new CRC32();
-        crc32.update(s.getBytes());
-        return crc32.getValue();
-    }
-
-    @RequiresApi(api = Build.VERSION_CODES.N)
-    private void prepareChatNotification(NotificationCompat.Builder notificationBuilder,
-                                         StatusBarNotification activeStatusBarNotification,
-                                         int systemNotificationId) {
-
-        final NotificationUser notificationUser = decryptedPushMessage.getNotificationUser();
-        final String userType = notificationUser.getType();
-
-        MessagingStyle style = null;
-        if (activeStatusBarNotification != null) {
-            style = MessagingStyle.extractMessagingStyleFromNotification(activeStatusBarNotification.getNotification());
-        }
-
-        Person.Builder person =
-                new Person.Builder()
-                    .setKey(signatureVerification.getUser().getId() + "@" + notificationUser.getId())
-                    .setName(EmojiCompat.get().process(notificationUser.getName()))
-                    .setBot("bot".equals(userType));
-
-        notificationBuilder.setOnlyAlertOnce(true);
-        addReplyAction(notificationBuilder, systemNotificationId);
-        addMarkAsReadAction(notificationBuilder, systemNotificationId);
-
-        if ("user".equals(userType) || "guest".equals(userType)) {
-            String baseUrl = signatureVerification.getUser().getBaseUrl();
-            String avatarUrl = "user".equals(userType) ?
-                ApiUtils.getUrlForAvatar(baseUrl, notificationUser.getId(), false) :
-                ApiUtils.getUrlForGuestAvatar(baseUrl, notificationUser.getName(), false);
-            person.setIcon(NotificationUtils.INSTANCE.loadAvatarSync(avatarUrl));
-        }
-
-        notificationBuilder.setStyle(getStyle(person.build(), style));
-    }
-
-    private PendingIntent buildIntentForAction(Class<?> cls, int systemNotificationId, int messageId) {
-        Intent actualIntent = new Intent(context, cls);
-
-        // NOTE - systemNotificationId is an internal ID used on the device only.
-        // It is NOT the same as the notification ID used in communication with the server.
-        actualIntent.putExtra(BundleKeys.KEY_SYSTEM_NOTIFICATION_ID, systemNotificationId);
-        actualIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID,
-                              Objects.requireNonNull(signatureVerification.getUser()).getId());
-        actualIntent.putExtra(BundleKeys.KEY_ROOM_TOKEN, decryptedPushMessage.getId());
-        actualIntent.putExtra(BundleKeys.KEY_MESSAGE_ID, messageId);
-
-        int intentFlag;
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-            intentFlag = PendingIntent.FLAG_MUTABLE|PendingIntent.FLAG_UPDATE_CURRENT;
-        } else {
-            intentFlag = PendingIntent.FLAG_UPDATE_CURRENT;
-        }
-
-        return PendingIntent.getBroadcast(context, systemNotificationId, actualIntent, intentFlag);
-    }
-
-    private void addMarkAsReadAction(NotificationCompat.Builder notificationBuilder, int systemNotificationId) {
-        if (decryptedPushMessage.getObjectId() != null) {
-            int messageId = 0;
-            try {
-                messageId = parseMessageId(decryptedPushMessage.getObjectId());
-            } catch (NumberFormatException nfe) {
-                Log.e(TAG, "Failed to parse messageId from objectId, skip adding mark-as-read action.", nfe);
-                return;
-            }
-
-            // Build a PendingIntent for the mark as read action
-            PendingIntent pendingIntent = buildIntentForAction(MarkAsReadReceiver.class,
-                                                               systemNotificationId,
-                                                               messageId);
-
-            NotificationCompat.Action action =
-                new NotificationCompat.Action.Builder(R.drawable.ic_eye,
-                                                      context.getResources().getString(R.string.nc_mark_as_read),
-                                                      pendingIntent)
-                    .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
-                    .setShowsUserInterface(false)
-                    .build();
-
-            notificationBuilder.addAction(action);
-        }
-    }
-
-    @RequiresApi(api = Build.VERSION_CODES.N)
-    private void addReplyAction(NotificationCompat.Builder notificationBuilder, int systemNotificationId) {
-        String replyLabel = context.getResources().getString(R.string.nc_reply);
-
-        RemoteInput remoteInput = new RemoteInput.Builder(NotificationUtils.KEY_DIRECT_REPLY)
-            .setLabel(replyLabel)
-            .build();
-
-        // Build a PendingIntent for the reply action
-        PendingIntent replyPendingIntent = buildIntentForAction(DirectReplyReceiver.class, systemNotificationId, 0);
-
-        NotificationCompat.Action replyAction =
-            new NotificationCompat.Action.Builder(R.drawable.ic_reply, replyLabel, replyPendingIntent)
-                .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
-                .setShowsUserInterface(false)
-                // Allows system to generate replies by context of conversation.
-                // https://developer.android.com/reference/androidx/core/app/NotificationCompat.Action.Builder#setAllowGeneratedReplies(boolean)
-                // Good question is - do we really want it?
-                .setAllowGeneratedReplies(true)
-                .addRemoteInput(remoteInput)
-                .build();
-
-        notificationBuilder.addAction(replyAction);
-    }
-
-    @RequiresApi(api = Build.VERSION_CODES.N)
-    private MessagingStyle getStyle(Person person, @Nullable MessagingStyle style) {
-        MessagingStyle newStyle = new MessagingStyle(person);
-
-        newStyle.setConversationTitle(decryptedPushMessage.getSubject());
-        newStyle.setGroupConversation(!"one2one".equals(conversationType));
-
-        if (style != null) {
-            style.getMessages().forEach(message -> newStyle.addMessage(
-                new MessagingStyle.Message(message.getText(),
-                                           message.getTimestamp(),
-                                           message.getPerson())));
-        }
-
-        newStyle.addMessage(decryptedPushMessage.getText(), decryptedPushMessage.getTimestamp(), person);
-        return newStyle;
-    }
-
-    private int parseMessageId(@NonNull String objectId) {
-        String[] objectIdParts = objectId.split("/");
-        if (objectIdParts.length < 2) {
-            throw new NumberFormatException("Invalid objectId, doesn't contain at least one '/'");
-        } else {
-            return Integer.parseInt(objectIdParts[1]);
-        }
-    }
-
-    private void sendNotification(int notificationId, Notification notification) {
-        NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
-        notificationManager.notify(notificationId, notification);
-
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-            // On devices with Android 8.0 (Oreo) or later, notification sound will be handled by the system
-            // if notifications have not been disabled by the user.
-            return;
-        }
-
-        if (!Notification.CATEGORY_CALL.equals(notification.category) || !muteCall) {
-            Uri soundUri = NotificationUtils.INSTANCE.getMessageRingtoneUri(context, appPreferences);
-            if (soundUri != null && !ApplicationWideCurrentRoomHolder.getInstance().isInCall() &&
-                    (DoNotDisturbUtils.INSTANCE.shouldPlaySound() || importantConversation)) {
-                AudioAttributes.Builder audioAttributesBuilder = new AudioAttributes.Builder().setContentType
-                        (AudioAttributes.CONTENT_TYPE_SONIFICATION);
-
-                if (CHAT.equals(decryptedPushMessage.getType()) || ROOM.equals(decryptedPushMessage.getType())) {
-                    audioAttributesBuilder.setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT);
-                } else {
-                    audioAttributesBuilder.setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_REQUEST);
-                }
-
-                MediaPlayer mediaPlayer = new MediaPlayer();
-                try {
-                    mediaPlayer.setDataSource(context, soundUri);
-                    mediaPlayer.setAudioAttributes(audioAttributesBuilder.build());
-
-                    mediaPlayer.setOnPreparedListener(mp -> mediaPlayer.start());
-                    mediaPlayer.setOnCompletionListener(MediaPlayer::release);
-
-                    mediaPlayer.prepareAsync();
-                } catch (IOException e) {
-                    Log.e(TAG, "Failed to set data source");
-                }
-            }
-        }
-    }
-
-    @NonNull
-    @Override
-    public Result doWork() {
-        NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this);
-
-        context = getApplicationContext();
-        Data data = getInputData();
-        String subject = data.getString(BundleKeys.KEY_NOTIFICATION_SUBJECT);
-        String signature = data.getString(BundleKeys.KEY_NOTIFICATION_SIGNATURE);
-
-        try {
-            byte[] base64DecodedSubject = Base64.decode(subject, Base64.DEFAULT);
-            byte[] base64DecodedSignature = Base64.decode(signature, Base64.DEFAULT);
-            PushUtils pushUtils = new PushUtils();
-            PrivateKey privateKey = (PrivateKey) pushUtils.readKeyFromFile(false);
-
-            try {
-                signatureVerification = pushUtils.verifySignature(base64DecodedSignature,
-                        base64DecodedSubject);
-
-                if (signatureVerification.getSignatureValid()) {
-                    Cipher cipher = Cipher.getInstance("RSA/None/PKCS1Padding");
-                    cipher.init(Cipher.DECRYPT_MODE, privateKey);
-                    byte[] decryptedSubject = cipher.doFinal(base64DecodedSubject);
-                    decryptedPushMessage = LoganSquare.parse(new String(decryptedSubject),
-                            DecryptedPushMessage.class);
-
-                    decryptedPushMessage.setTimestamp(System.currentTimeMillis());
-                    if (decryptedPushMessage.getDelete()) {
-                        NotificationUtils.INSTANCE.cancelExistingNotificationWithId(
-                            context,
-                            signatureVerification.getUser(),
-                            decryptedPushMessage.getNotificationId());
-                    } else if (decryptedPushMessage.getDeleteAll()) {
-                        NotificationUtils.INSTANCE.cancelAllNotificationsForAccount(
-                            context,
-                            signatureVerification.getUser());
-                    } else if (decryptedPushMessage.getDeleteMultiple()) {
-                        for (long notificationId : decryptedPushMessage.getNotificationIds()) {
-                            NotificationUtils.INSTANCE.cancelExistingNotificationWithId(
-                                context,
-                                signatureVerification.getUser(),
-                                notificationId);
-                        }
-                    } else {
-                        credentials = ApiUtils.getCredentials(signatureVerification.getUser().getUsername(),
-                                signatureVerification.getUser().getToken());
-
-                        ncApi = retrofit.newBuilder().client(okHttpClient.newBuilder().cookieJar(new
-                                JavaNetCookieJar(new CookieManager())).build()).build().create(NcApi.class);
-
-                        boolean shouldShowNotification = "spreed".equals(decryptedPushMessage.getApp());
-
-                        if (shouldShowNotification) {
-                            Intent intent;
-                            Bundle bundle = new Bundle();
-
-
-                            boolean startACall = "call".equals(decryptedPushMessage.getType());
-                            if (startACall) {
-                                intent = new Intent(context, CallActivity.class);
-                            } else {
-                                intent = new Intent(context, MainActivity.class);
-                            }
-
-                            intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
-
-                            bundle.putString(BundleKeys.KEY_ROOM_TOKEN, decryptedPushMessage.getId());
-
-                            bundle.putParcelable(BundleKeys.KEY_USER_ENTITY,
-                                                 signatureVerification.getUser());
-
-                            bundle.putBoolean(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL,
-                                              startACall);
-
-                            intent.putExtras(bundle);
-
-                            Log.e(TAG, "Notification: " + decryptedPushMessage.getType());
-
-                            switch (decryptedPushMessage.getType()) {
-                                case "call":
-                                    if (bundle.containsKey(BundleKeys.KEY_ROOM_TOKEN)) {
-                                        showNotificationForCallWithNoPing(intent);
-                                    }
-                                    break;
-                                case "room":
-                                    if (bundle.containsKey(BundleKeys.KEY_ROOM_TOKEN)) {
-                                        showNotificationWithObjectData(intent);
-                                    }
-                                    break;
-                                case "chat":
-                                    if (decryptedPushMessage.getNotificationId() != Long.MIN_VALUE) {
-                                        showNotificationWithObjectData(intent);
-                                    } else {
-                                        showNotification(intent);
-                                    }
-                                    break;
-                                default:
-                                    break;
-                            }
-
-                        }
-                    }
-                }
-            } catch (NoSuchAlgorithmException e1) {
-                Log.d(TAG, "No proper algorithm to decrypt the message " + e1.getLocalizedMessage());
-            } catch (NoSuchPaddingException e1) {
-                Log.d(TAG, "No proper padding to decrypt the message " + e1.getLocalizedMessage());
-            } catch (InvalidKeyException e1) {
-                Log.d(TAG, "Invalid private key " + e1.getLocalizedMessage());
-            }
-        } catch (Exception exception) {
-            Log.d(TAG, "Something went very wrong " + exception.getLocalizedMessage());
-        }
-        return Result.success();
-    }
-}

+ 849 - 0
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 <dev@mhibbe.de>
+ * Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
+ * Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.talk.jobs
+
+import android.app.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Context.NOTIFICATION_SERVICE
+import android.content.Intent
+import android.graphics.Bitmap
+import android.media.AudioAttributes
+import android.media.MediaPlayer
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.SystemClock
+import android.service.notification.StatusBarNotification
+import android.text.TextUtils
+import android.util.Base64
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.Person
+import androidx.core.app.RemoteInput
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.toBitmap
+import androidx.emoji.text.EmojiCompat
+import androidx.work.Data
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import autodagger.AutoInjector
+import com.bluelinelabs.logansquare.LoganSquare
+import com.nextcloud.talk.R
+import com.nextcloud.talk.activities.CallNotificationActivity
+import com.nextcloud.talk.activities.MainActivity
+import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
+import com.nextcloud.talk.models.SignatureVerification
+import com.nextcloud.talk.models.json.chat.ChatUtils.Companion.getParsedMessage
+import com.nextcloud.talk.models.json.conversations.RoomOverall
+import com.nextcloud.talk.models.json.notifications.NotificationOverall
+import com.nextcloud.talk.models.json.participants.Participant
+import com.nextcloud.talk.models.json.participants.ParticipantsOverall
+import com.nextcloud.talk.models.json.push.DecryptedPushMessage
+import com.nextcloud.talk.models.json.push.NotificationUser
+import com.nextcloud.talk.receivers.DirectReplyReceiver
+import com.nextcloud.talk.receivers.MarkAsReadReceiver
+import com.nextcloud.talk.utils.ApiUtils
+import com.nextcloud.talk.utils.DoNotDisturbUtils.shouldPlaySound
+import com.nextcloud.talk.utils.NotificationUtils
+import com.nextcloud.talk.utils.NotificationUtils.cancelAllNotificationsForAccount
+import com.nextcloud.talk.utils.NotificationUtils.cancelNotification
+import com.nextcloud.talk.utils.NotificationUtils.findNotificationForRoom
+import com.nextcloud.talk.utils.NotificationUtils.getCallRingtoneUri
+import com.nextcloud.talk.utils.NotificationUtils.getMessageRingtoneUri
+import com.nextcloud.talk.utils.NotificationUtils.loadAvatarSync
+import com.nextcloud.talk.utils.PushUtils
+import com.nextcloud.talk.utils.bundle.BundleKeys
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FROM_NOTIFICATION_START_CALL
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_MESSAGE_ID
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_NOTIFICATION_ID
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY
+import com.nextcloud.talk.utils.preferences.AppPreferences
+import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder
+import io.reactivex.Observable
+import io.reactivex.Observer
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.disposables.Disposable
+import io.reactivex.schedulers.Schedulers
+import okhttp3.JavaNetCookieJar
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import java.io.IOException
+import java.net.CookieManager
+import java.security.InvalidKeyException
+import java.security.NoSuchAlgorithmException
+import java.security.PrivateKey
+import java.util.concurrent.TimeUnit
+import java.util.function.Consumer
+import java.util.zip.CRC32
+import javax.crypto.Cipher
+import javax.crypto.NoSuchPaddingException
+import javax.inject.Inject
+
+@AutoInjector(NextcloudTalkApplication::class)
+class NotificationWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
+
+    @Inject
+    lateinit var appPreferences: AppPreferences
+
+    @JvmField
+    @Inject
+    var arbitraryStorageManager: ArbitraryStorageManager? = null
+
+    @JvmField
+    @Inject
+    var retrofit: Retrofit? = null
+
+    @JvmField
+    @Inject
+    var okHttpClient: OkHttpClient? = null
+    private lateinit var credentials: String
+    private lateinit var ncApi: NcApi
+    private lateinit var pushMessage: DecryptedPushMessage
+    private lateinit var signatureVerification: SignatureVerification
+    private var context: Context? = null
+    private var conversationType: String? = "one2one"
+    private var muteCall = false
+    private var importantConversation = false
+    private lateinit var notificationManager: NotificationManagerCompat
+
+    override fun doWork(): Result {
+        sharedApplication!!.componentApplication.inject(this)
+        context = applicationContext
+
+        initDecryptedData(inputData)
+        initNcApiAndCredentials()
+
+        notificationManager = NotificationManagerCompat.from(context!!)
+
+        pushMessage.timestamp = System.currentTimeMillis()
+
+        Log.d(TAG, pushMessage.toString())
+        Log.d(TAG, "pushMessage.id (=KEY_ROOM_TOKEN): " + pushMessage.id)
+        Log.d(TAG, "pushMessage.notificationId: " + pushMessage.notificationId)
+        Log.d(TAG, "pushMessage.notificationIds: " + pushMessage.notificationIds)
+        Log.d(TAG, "pushMessage.timestamp: " + pushMessage.timestamp)
+
+        if (pushMessage.delete) {
+            cancelNotification(context, signatureVerification.user!!, pushMessage.notificationId)
+        } else if (pushMessage.deleteAll) {
+            cancelAllNotificationsForAccount(context, signatureVerification.user!!)
+        } else if (pushMessage.deleteMultiple) {
+            for (notificationId in pushMessage.notificationIds!!) {
+                cancelNotification(context, signatureVerification.user!!, notificationId)
+            }
+        } else if (isSpreedNotification()) {
+            Log.d(TAG, "pushMessage.type: " + pushMessage.type)
+            when (pushMessage.type) {
+                "chat" -> handleChatNotification()
+                "room" -> handleRoomNotification()
+                "call" -> handleCallNotification()
+                else -> Log.e(TAG, "unknown pushMessage.type")
+            }
+        } else {
+            Log.d(TAG, "a pushMessage that is not for spreed was received.")
+        }
+
+        return Result.success()
+    }
+
+    private fun handleChatNotification() {
+        val chatIntent = Intent(context, MainActivity::class.java)
+        chatIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
+        val chatBundle = Bundle()
+        chatBundle.putString(KEY_ROOM_TOKEN, pushMessage.id)
+        chatBundle.putParcelable(KEY_USER_ENTITY, signatureVerification.user)
+        chatBundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, false)
+        chatIntent.putExtras(chatBundle)
+        if (pushMessage.notificationId != Long.MIN_VALUE) {
+            showNotificationWithObjectData(chatIntent)
+        } else {
+            showNotification(chatIntent)
+        }
+    }
+
+    /**
+     * handle messages with type 'room', e.g. "xxx invited you to a group conversation"
+     */
+    private fun handleRoomNotification() {
+        val intent = Intent(context, MainActivity::class.java)
+        intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
+        val bundle = Bundle()
+        bundle.putString(KEY_ROOM_TOKEN, pushMessage.id)
+        bundle.putParcelable(KEY_USER_ENTITY, signatureVerification.user)
+        bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, false)
+        intent.putExtras(bundle)
+        if (bundle.containsKey(KEY_ROOM_TOKEN)) {
+            showNotificationWithObjectData(intent)
+        }
+    }
+
+    private fun handleCallNotification() {
+        val fullScreenIntent = Intent(context, CallNotificationActivity::class.java)
+        val bundle = Bundle()
+        bundle.putString(KEY_ROOM_TOKEN, pushMessage.id)
+        bundle.putParcelable(KEY_USER_ENTITY, signatureVerification.user)
+        bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, true)
+        fullScreenIntent.putExtras(bundle)
+        fullScreenIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
+
+        val requestCode = System.currentTimeMillis().toInt()
+
+        val fullScreenPendingIntent = PendingIntent.getActivity(
+            context,
+            requestCode,
+            fullScreenIntent,
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+                PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+            } else {
+                PendingIntent.FLAG_UPDATE_CURRENT
+            }
+        )
+
+        val soundUri = getCallRingtoneUri(applicationContext, appPreferences)
+        val notificationChannelId = NotificationUtils
+            .NotificationChannels.NOTIFICATION_CHANNEL_CALLS_V4.name
+        val uri = Uri.parse(signatureVerification.user!!.baseUrl)
+        val baseUrl = uri.host
+
+        val notification =
+            NotificationCompat.Builder(applicationContext, notificationChannelId)
+                .setPriority(NotificationCompat.PRIORITY_HIGH)
+                .setCategory(NotificationCompat.CATEGORY_CALL)
+                .setSmallIcon(R.drawable.ic_call_black_24dp)
+                .setSubText(baseUrl)
+                .setShowWhen(true)
+                .setWhen(pushMessage.timestamp)
+                .setContentTitle(EmojiCompat.get().process(pushMessage.subject))
+                .setAutoCancel(true)
+                .setOngoing(true)
+                .setContentIntent(fullScreenPendingIntent)
+                .setFullScreenIntent(fullScreenPendingIntent, true)
+                .setSound(soundUri)
+                .build()
+        notification.flags = notification.flags or Notification.FLAG_INSISTENT
+
+        sendNotification(pushMessage.timestamp.toInt(), notification)
+
+        checkIfCallIsActive(signatureVerification, pushMessage)
+    }
+
+    private fun initNcApiAndCredentials() {
+        credentials = ApiUtils.getCredentials(
+            signatureVerification.user!!.username,
+            signatureVerification.user!!.token
+        )
+        ncApi = retrofit!!.newBuilder().client(
+            okHttpClient!!.newBuilder().cookieJar(
+                JavaNetCookieJar(
+                    CookieManager()
+                )
+            ).build()
+        ).build().create(
+            NcApi::class.java
+        )
+    }
+
+    @Suppress("TooGenericExceptionCaught", "NestedBlockDepth", "ComplexMethod", "LongMethod")
+    private fun initDecryptedData(inputData: Data) {
+        val subject = inputData.getString(BundleKeys.KEY_NOTIFICATION_SUBJECT)
+        val signature = inputData.getString(BundleKeys.KEY_NOTIFICATION_SIGNATURE)
+        try {
+            val base64DecodedSubject = Base64.decode(subject, Base64.DEFAULT)
+            val base64DecodedSignature = Base64.decode(signature, Base64.DEFAULT)
+            val pushUtils = PushUtils()
+            val privateKey = pushUtils.readKeyFromFile(false) as PrivateKey
+            try {
+                signatureVerification = pushUtils.verifySignature(
+                    base64DecodedSignature,
+                    base64DecodedSubject
+                )
+                if (signatureVerification.signatureValid) {
+                    val cipher = Cipher.getInstance("RSA/None/PKCS1Padding")
+                    cipher.init(Cipher.DECRYPT_MODE, privateKey)
+                    val decryptedSubject = cipher.doFinal(base64DecodedSubject)
+
+                    pushMessage = LoganSquare.parse(
+                        String(decryptedSubject),
+                        DecryptedPushMessage::class.java
+                    )
+                }
+            } catch (e: NoSuchAlgorithmException) {
+                Log.e(TAG, "No proper algorithm to decrypt the message ", e)
+            } catch (e: NoSuchPaddingException) {
+                Log.e(TAG, "No proper padding to decrypt the message ", e)
+            } catch (e: InvalidKeyException) {
+                Log.e(TAG, "Invalid private key ", e)
+            }
+        } catch (e: Exception) {
+            Log.e(TAG, "Error occurred while initializing decoded data ", e)
+        }
+    }
+
+    private fun isSpreedNotification() = SPREED_APP == pushMessage.app
+
+    private fun showNotificationWithObjectData(intent: Intent) {
+        val user = signatureVerification.user
+
+        // see https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md
+        ncApi.getNotification(
+            credentials,
+            ApiUtils.getUrlForNotificationWithId(
+                user!!.baseUrl,
+                (pushMessage.notificationId!!).toString()
+            )
+        )
+            .blockingSubscribe(object : Observer<NotificationOverall> {
+                override fun onSubscribe(d: Disposable) {
+                    // unused atm
+                }
+
+                override fun onNext(notificationOverall: NotificationOverall) {
+                    val ncNotification = notificationOverall.ocs!!.notification
+
+                    if (ncNotification!!.messageRichParameters != null &&
+                        ncNotification.messageRichParameters!!.size > 0
+                    ) {
+                        pushMessage.text = getParsedMessage(
+                            ncNotification.messageRich,
+                            ncNotification.messageRichParameters
+                        )
+                    } else {
+                        pushMessage.text = ncNotification.message
+                    }
+
+                    val subjectRichParameters = ncNotification.subjectRichParameters
+
+                    pushMessage.timestamp = ncNotification.datetime!!.millis
+
+                    if (subjectRichParameters != null && subjectRichParameters.size > 0) {
+                        val callHashMap = subjectRichParameters["call"]
+                        val userHashMap = subjectRichParameters["user"]
+                        val guestHashMap = subjectRichParameters["guest"]
+                        if (callHashMap != null && callHashMap.size > 0 && callHashMap.containsKey("name")) {
+                            if (subjectRichParameters.containsKey("reaction")) {
+                                pushMessage.subject = ""
+                                pushMessage.text = ncNotification.subject
+                            } else if (ncNotification.objectType == "chat") {
+                                pushMessage.subject = callHashMap["name"]!!
+                            } else {
+                                pushMessage.subject = ncNotification.subject!!
+                            }
+                            if (callHashMap.containsKey("call-type")) {
+                                conversationType = callHashMap["call-type"]
+                            }
+                        }
+                        val notificationUser = NotificationUser()
+                        if (userHashMap != null && userHashMap.isNotEmpty()) {
+                            notificationUser.id = userHashMap["id"]
+                            notificationUser.type = userHashMap["type"]
+                            notificationUser.name = userHashMap["name"]
+                            pushMessage.notificationUser = notificationUser
+                        } else if (guestHashMap != null && guestHashMap.isNotEmpty()) {
+                            notificationUser.id = guestHashMap["id"]
+                            notificationUser.type = guestHashMap["type"]
+                            notificationUser.name = guestHashMap["name"]
+                            pushMessage.notificationUser = notificationUser
+                        }
+                    }
+                    pushMessage.objectId = ncNotification.objectId
+                    showNotification(intent)
+                }
+
+                override fun onError(e: Throwable) {
+                    // unused atm
+                }
+
+                override fun onComplete() {
+                    // unused atm
+                }
+            })
+    }
+
+    @Suppress("MagicNumber")
+    private fun showNotification(intent: Intent) {
+        val largeIcon: Bitmap
+        val priority = NotificationCompat.PRIORITY_HIGH
+        val smallIcon: Int = R.drawable.ic_logo
+        val category: String = if (CHAT == pushMessage.type || ROOM == pushMessage.type) {
+            Notification.CATEGORY_MESSAGE
+        } else {
+            Notification.CATEGORY_CALL
+        }
+        when (conversationType) {
+            "one2one" -> {
+                pushMessage.subject = ""
+                largeIcon = ContextCompat.getDrawable(context!!, R.drawable.ic_people_group_black_24px)?.toBitmap()!!
+            }
+            "group" ->
+                largeIcon = ContextCompat.getDrawable(context!!, R.drawable.ic_people_group_black_24px)?.toBitmap()!!
+            "public" -> largeIcon = ContextCompat.getDrawable(context!!, R.drawable.ic_link_black_24px)?.toBitmap()!!
+            else -> // assuming one2one
+                largeIcon = if (CHAT == pushMessage.type || ROOM == pushMessage.type) {
+                    ContextCompat.getDrawable(context!!, R.drawable.ic_comment)?.toBitmap()!!
+                } else {
+                    ContextCompat.getDrawable(context!!, R.drawable.ic_call_black_24dp)?.toBitmap()!!
+                }
+        }
+
+        // Use unique request code to make sure that a new PendingIntent gets created for each notification
+        // See https://github.com/nextcloud/talk-android/issues/2111
+        val requestCode = System.currentTimeMillis().toInt()
+        val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            PendingIntent.FLAG_MUTABLE
+        } else {
+            0
+        }
+        val pendingIntent = PendingIntent.getActivity(context, requestCode, intent, intentFlag)
+        val uri = Uri.parse(signatureVerification.user!!.baseUrl)
+        val baseUrl = uri.host
+        val notificationBuilder = NotificationCompat.Builder(context!!, "1")
+            .setLargeIcon(largeIcon)
+            .setSmallIcon(smallIcon)
+            .setCategory(category)
+            .setPriority(priority)
+            .setSubText(baseUrl)
+            .setWhen(pushMessage.timestamp)
+            .setShowWhen(true)
+            .setContentIntent(pendingIntent)
+            .setAutoCancel(true)
+        if (!TextUtils.isEmpty(pushMessage.subject)) {
+            notificationBuilder.setContentTitle(
+                EmojiCompat.get().process(pushMessage.subject)
+            )
+        }
+        if (!TextUtils.isEmpty(pushMessage.text)) {
+            notificationBuilder.setContentText(
+                EmojiCompat.get().process(pushMessage.text!!)
+            )
+        }
+        if (Build.VERSION.SDK_INT >= 23) {
+            // This method should exist since API 21, but some phones don't have it
+            // So as a safeguard, we don't use it until 23
+            notificationBuilder.color = context!!.resources.getColor(R.color.colorPrimary)
+        }
+        val notificationInfoBundle = Bundle()
+        notificationInfoBundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!)
+        // could be an ID or a TOKEN
+        notificationInfoBundle.putString(KEY_ROOM_TOKEN, pushMessage.id)
+        notificationInfoBundle.putLong(KEY_NOTIFICATION_ID, pushMessage.notificationId!!)
+        notificationBuilder.setExtras(notificationInfoBundle)
+
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            if (CHAT == pushMessage.type || ROOM == pushMessage.type) {
+                notificationBuilder.setChannelId(
+                    NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_MESSAGES_V4.name
+                )
+            }
+        } else {
+            // red color for the lights
+            notificationBuilder.setLights(-0x10000, 200, 200)
+        }
+
+        notificationBuilder.setContentIntent(pendingIntent)
+        val groupName = signatureVerification.user!!.id.toString() + "@" + pushMessage.id
+        notificationBuilder.setGroup(calculateCRC32(groupName).toString())
+        val activeStatusBarNotification = findNotificationForRoom(
+            context,
+            signatureVerification.user!!,
+            pushMessage.id!!
+        )
+
+        // NOTE - systemNotificationId is an internal ID used on the device only.
+        // It is NOT the same as the notification ID used in communication with the server.
+        val systemNotificationId: Int =
+            activeStatusBarNotification?.id ?: calculateCRC32(System.currentTimeMillis().toString()).toInt()
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && CHAT == pushMessage.type &&
+            pushMessage.notificationUser != null
+        ) {
+            prepareChatNotification(notificationBuilder, activeStatusBarNotification, systemNotificationId)
+        }
+        sendNotification(systemNotificationId, notificationBuilder.build())
+    }
+
+    private fun calculateCRC32(s: String): Long {
+        val crc32 = CRC32()
+        crc32.update(s.toByteArray())
+        return crc32.value
+    }
+
+    @RequiresApi(api = Build.VERSION_CODES.N)
+    private fun prepareChatNotification(
+        notificationBuilder: NotificationCompat.Builder,
+        activeStatusBarNotification: StatusBarNotification?,
+        systemNotificationId: Int
+    ) {
+        val notificationUser = pushMessage.notificationUser
+        val userType = notificationUser!!.type
+        var style: NotificationCompat.MessagingStyle? = null
+        if (activeStatusBarNotification != null) {
+            style = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(
+                activeStatusBarNotification.notification
+            )
+        }
+        val person = Person.Builder()
+            .setKey(signatureVerification.user!!.id.toString() + "@" + notificationUser.id)
+            .setName(EmojiCompat.get().process(notificationUser.name!!))
+            .setBot("bot" == userType)
+        notificationBuilder.setOnlyAlertOnce(true)
+        addReplyAction(notificationBuilder, systemNotificationId)
+        addMarkAsReadAction(notificationBuilder, systemNotificationId)
+
+        if ("user" == userType || "guest" == userType) {
+            val baseUrl = signatureVerification.user!!.baseUrl
+            val avatarUrl = if ("user" == userType) ApiUtils.getUrlForAvatar(
+                baseUrl,
+                notificationUser.id,
+                false
+            ) else ApiUtils.getUrlForGuestAvatar(baseUrl, notificationUser.name, false)
+            person.setIcon(loadAvatarSync(avatarUrl))
+        }
+        notificationBuilder.setStyle(getStyle(person.build(), style))
+    }
+
+    private fun buildIntentForAction(cls: Class<*>, systemNotificationId: Int, messageId: Int): PendingIntent {
+        val actualIntent = Intent(context, cls)
+
+        // NOTE - systemNotificationId is an internal ID used on the device only.
+        // It is NOT the same as the notification ID used in communication with the server.
+        actualIntent.putExtra(KEY_SYSTEM_NOTIFICATION_ID, systemNotificationId)
+        actualIntent.putExtra(KEY_INTERNAL_USER_ID, signatureVerification.user?.id)
+        actualIntent.putExtra(KEY_ROOM_TOKEN, pushMessage.id)
+        actualIntent.putExtra(KEY_MESSAGE_ID, messageId)
+
+        val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+        } else {
+            PendingIntent.FLAG_UPDATE_CURRENT
+        }
+        return PendingIntent.getBroadcast(context, systemNotificationId, actualIntent, intentFlag)
+    }
+
+    private fun addMarkAsReadAction(notificationBuilder: NotificationCompat.Builder, systemNotificationId: Int) {
+        if (pushMessage.objectId != null) {
+            val messageId: Int = try {
+                parseMessageId(pushMessage.objectId!!)
+            } catch (nfe: NumberFormatException) {
+                Log.e(TAG, "Failed to parse messageId from objectId, skip adding mark-as-read action.", nfe)
+                return
+            }
+
+            val pendingIntent = buildIntentForAction(
+                MarkAsReadReceiver::class.java,
+                systemNotificationId,
+                messageId
+            )
+            val action = NotificationCompat.Action.Builder(
+                R.drawable.ic_eye,
+                context!!.resources.getString(R.string.nc_mark_as_read),
+                pendingIntent
+            )
+                .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
+                .setShowsUserInterface(false)
+                .build()
+            notificationBuilder.addAction(action)
+        }
+    }
+
+    @RequiresApi(api = Build.VERSION_CODES.N)
+    private fun addReplyAction(notificationBuilder: NotificationCompat.Builder, systemNotificationId: Int) {
+        val replyLabel = context!!.resources.getString(R.string.nc_reply)
+        val remoteInput = RemoteInput.Builder(NotificationUtils.KEY_DIRECT_REPLY)
+            .setLabel(replyLabel)
+            .build()
+
+        val replyPendingIntent = buildIntentForAction(DirectReplyReceiver::class.java, systemNotificationId, 0)
+        val replyAction = NotificationCompat.Action.Builder(R.drawable.ic_reply, replyLabel, replyPendingIntent)
+            .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
+            .setShowsUserInterface(false)
+            .setAllowGeneratedReplies(true)
+            .addRemoteInput(remoteInput)
+            .build()
+        notificationBuilder.addAction(replyAction)
+    }
+
+    @RequiresApi(api = Build.VERSION_CODES.N)
+    private fun getStyle(person: Person, style: NotificationCompat.MessagingStyle?): NotificationCompat.MessagingStyle {
+        val newStyle = NotificationCompat.MessagingStyle(person)
+        newStyle.conversationTitle = pushMessage.subject
+        newStyle.isGroupConversation = "one2one" != conversationType
+        style?.messages?.forEach(
+            Consumer { message: NotificationCompat.MessagingStyle.Message ->
+                newStyle.addMessage(
+                    NotificationCompat.MessagingStyle.Message(
+                        message.text,
+                        message.timestamp,
+                        message.person
+                    )
+                )
+            }
+        )
+        newStyle.addMessage(pushMessage.text, pushMessage.timestamp, person)
+        return newStyle
+    }
+
+    @Throws(NumberFormatException::class)
+    private fun parseMessageId(objectId: String): Int {
+        val objectIdParts = objectId.split("/".toRegex()).toTypedArray()
+        return if (objectIdParts.size < 2) {
+            throw NumberFormatException("Invalid objectId, doesn't contain at least one '/'")
+        } else {
+            objectIdParts[1].toInt()
+        }
+    }
+
+    private fun sendNotification(notificationId: Int, notification: Notification) {
+        Log.d(TAG, "show notification with id $notificationId")
+        notificationManager.notify(notificationId, notification)
+
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            // On devices with Android 8.0 (Oreo) or later, notification sound will be handled by the system
+            // if notifications have not been disabled by the user.
+            return
+        }
+        if (Notification.CATEGORY_CALL != notification.category || !muteCall) {
+            val soundUri = getMessageRingtoneUri(context!!, appPreferences)
+            if (soundUri != null && !ApplicationWideCurrentRoomHolder.getInstance().isInCall &&
+                (shouldPlaySound() || importantConversation)
+            ) {
+                val audioAttributesBuilder =
+                    AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+                if (CHAT == pushMessage.type || ROOM == pushMessage.type) {
+                    audioAttributesBuilder.setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT)
+                } else {
+                    audioAttributesBuilder.setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_REQUEST)
+                }
+                val mediaPlayer = MediaPlayer()
+                try {
+                    mediaPlayer.setDataSource(context!!, soundUri)
+                    mediaPlayer.setAudioAttributes(audioAttributesBuilder.build())
+                    mediaPlayer.setOnPreparedListener { mediaPlayer.start() }
+                    mediaPlayer.setOnCompletionListener { obj: MediaPlayer -> obj.release() }
+                    mediaPlayer.prepareAsync()
+                } catch (e: IOException) {
+                    Log.e(TAG, "Failed to set data source")
+                }
+            }
+        }
+    }
+
+    private fun removeNotification(notificationId: Int) {
+        Log.d(TAG, "removed notification with id $notificationId")
+        notificationManager.cancel(notificationId)
+    }
+
+    private fun checkIfCallIsActive(
+        signatureVerification: SignatureVerification,
+        decryptedPushMessage: DecryptedPushMessage
+    ) {
+        Log.d(TAG, "checkIfCallIsActive")
+        var hasParticipantsInCall = true
+        var inCallOnDifferentDevice = false
+
+        val apiVersion = ApiUtils.getConversationApiVersion(
+            signatureVerification.user,
+            intArrayOf(ApiUtils.APIv4, 1)
+        )
+
+        var isCallNotificationVisible = true
+
+        ncApi.getPeersForCall(
+            credentials,
+            ApiUtils.getUrlForCall(
+                apiVersion,
+                signatureVerification.user!!.baseUrl,
+                decryptedPushMessage.id
+            )
+        )
+            .repeatWhen { completed ->
+                completed.zipWith(Observable.range(TIMER_START, TIMER_COUNT)) { _, i -> i }
+                    .flatMap { Observable.timer(TIMER_DELAY, TimeUnit.SECONDS) }
+                    .takeWhile { isCallNotificationVisible && hasParticipantsInCall && !inCallOnDifferentDevice }
+            }
+            .subscribeOn(Schedulers.io())
+            .subscribe(object : Observer<ParticipantsOverall> {
+                override fun onSubscribe(d: Disposable) = Unit
+
+                @RequiresApi(Build.VERSION_CODES.M)
+                override fun onNext(participantsOverall: ParticipantsOverall) {
+                    val participantList: List<Participant> = participantsOverall.ocs!!.data!!
+                    hasParticipantsInCall = participantList.isNotEmpty()
+                    if (hasParticipantsInCall) {
+                        for (participant in participantList) {
+                            if (participant.actorId == signatureVerification.user!!.userId &&
+                                participant.actorType == Participant.ActorType.USERS
+                            ) {
+                                inCallOnDifferentDevice = true
+                                break
+                            }
+                        }
+                    }
+                    if (inCallOnDifferentDevice) {
+                        Log.d(TAG, "inCallOnDifferentDevice is true")
+                        removeNotification(decryptedPushMessage.timestamp.toInt())
+                    }
+
+                    if (!hasParticipantsInCall) {
+                        showMissedCallNotification()
+                        Log.d(TAG, "no participants in call")
+                        removeNotification(decryptedPushMessage.timestamp.toInt())
+                    }
+
+                    isCallNotificationVisible = isCallNotificationVisible(decryptedPushMessage)
+                }
+
+                override fun onError(e: Throwable) {
+                    Log.e(TAG, "Error in getPeersForCall", e)
+                }
+
+                @RequiresApi(Build.VERSION_CODES.M)
+                override fun onComplete() {
+
+                    if (isCallNotificationVisible) {
+                        // this state can be reached when call timeout is reached.
+                        showMissedCallNotification()
+                    }
+
+                    removeNotification(decryptedPushMessage.timestamp.toInt())
+                }
+            })
+    }
+
+    fun showMissedCallNotification() {
+        val apiVersion = ApiUtils.getConversationApiVersion(
+            signatureVerification.user,
+            intArrayOf(
+                ApiUtils.APIv4,
+                ApiUtils.APIv3, 1
+            )
+        )
+        ncApi.getRoom(
+            credentials,
+            ApiUtils.getUrlForRoom(
+                apiVersion, signatureVerification.user?.baseUrl,
+                pushMessage.id
+            )
+        )
+            .subscribeOn(Schedulers.io())
+            .retry(GET_ROOM_RETRY_COUNT)
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribe(object : Observer<RoomOverall> {
+                override fun onSubscribe(d: Disposable) {
+                    // unused atm
+                }
+
+                override fun onNext(roomOverall: RoomOverall) {
+                    val currentConversation = roomOverall.ocs!!.data
+                    val notificationBuilder: NotificationCompat.Builder?
+
+                    notificationBuilder = NotificationCompat.Builder(
+                        context!!,
+                        NotificationUtils.NotificationChannels
+                            .NOTIFICATION_CHANNEL_MESSAGES_V4.name
+                    )
+
+                    val notification: Notification = notificationBuilder
+                        .setContentTitle(
+                            String.format(
+                                context!!.resources.getString(R.string.nc_missed_call),
+                                currentConversation!!.displayName
+                            )
+                        )
+                        .setSmallIcon(R.drawable.ic_baseline_phone_missed_24)
+                        .setOngoing(false)
+                        .setAutoCancel(true)
+                        .setPriority(NotificationCompat.PRIORITY_LOW)
+                        .setContentIntent(getIntentToOpenConversation())
+                        .build()
+
+                    val notificationId: Int = SystemClock.uptimeMillis().toInt()
+                    notificationManager.notify(notificationId, notification)
+                    Log.d(TAG, "'you missed a call' notification was created")
+                }
+
+                override fun onError(e: Throwable) {
+                    Log.e(TAG, "An error occurred while fetching room for the 'missed call' notification", e)
+                }
+
+                override fun onComplete() {
+                    // unused atm
+                }
+            })
+    }
+
+    private fun getIntentToOpenConversation(): PendingIntent? {
+        val bundle = Bundle()
+        val intent = Intent(context, MainActivity::class.java)
+        intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
+
+        bundle.putString(KEY_ROOM_TOKEN, pushMessage.id)
+        bundle.putParcelable(KEY_USER_ENTITY, signatureVerification.user)
+        bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, false)
+
+        intent.putExtras(bundle)
+
+        val requestCode = System.currentTimeMillis().toInt()
+        val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            PendingIntent.FLAG_MUTABLE
+        } else {
+            0
+        }
+        return PendingIntent.getActivity(context, requestCode, intent, intentFlag)
+    }
+
+    @RequiresApi(Build.VERSION_CODES.M)
+    private fun isCallNotificationVisible(decryptedPushMessage: DecryptedPushMessage): Boolean {
+        var isVisible = false
+
+        val notificationManager = context!!.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+        val notifications = notificationManager.activeNotifications
+        for (notification in notifications) {
+            if (notification.id == decryptedPushMessage.timestamp.toInt()) {
+                isVisible = true
+                break
+            }
+        }
+        return isVisible
+    }
+
+    companion object {
+        val TAG = NotificationWorker::class.simpleName
+        private const val CHAT = "chat"
+        private const val ROOM = "room"
+        private const val SPREED_APP = "spreed"
+        private const val TIMER_START = 1
+        private const val TIMER_COUNT = 12
+        private const val TIMER_DELAY: Long = 5
+        private const val GET_ROOM_RETRY_COUNT: Long = 3
+    }
+}

+ 1 - 1
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,

+ 1 - 1
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<String, HashMap<String, String>>?,
+    var messageRichParameters: HashMap<String?, HashMap<String?, String?>>?,
     @JsonField(name = ["link"])
     var link: String?,
     @JsonField(name = ["actions"])

+ 2 - 0
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"])

+ 1 - 0
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

+ 1 - 0
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;
     }

+ 2 - 1
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)

+ 5 - 0
app/src/main/res/drawable/ic_baseline_phone_missed_24.xml

@@ -0,0 +1,5 @@
+<vector android:autoMirrored="true" android:height="24dp"
+    android:tint="#000000" android:viewportHeight="24"
+    android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M6.5,5.5L12,11l7,-7 -1,-1 -6,6 -4.5,-4.5L11,4.5L11,3L5,3v6h1.5L6.5,5.5zM23.71,16.67C20.66,13.78 16.54,12 12,12 7.46,12 3.34,13.78 0.29,16.67c-0.18,0.18 -0.29,0.43 -0.29,0.71s0.11,0.53 0.29,0.71l2.48,2.48c0.18,0.18 0.43,0.29 0.71,0.29 0.27,0 0.52,-0.11 0.7,-0.28 0.79,-0.74 1.69,-1.36 2.66,-1.85 0.33,-0.16 0.56,-0.5 0.56,-0.9v-3.1c1.45,-0.48 3,-0.73 4.6,-0.73 1.6,0 3.15,0.25 4.6,0.72v3.1c0,0.39 0.23,0.74 0.56,0.9 0.98,0.49 1.87,1.12 2.67,1.85 0.18,0.18 0.43,0.28 0.7,0.28 0.28,0 0.53,-0.11 0.71,-0.29l2.48,-2.48c0.18,-0.18 0.29,-0.43 0.29,-0.71s-0.12,-0.52 -0.3,-0.7z"/>
+</vector>

+ 1 - 1
app/src/main/res/values/strings.xml

@@ -210,6 +210,7 @@
     <string name="nc_call_state_in_call">%1$s in call</string>
     <string name="nc_call_state_with_phone">%1$s with phone</string>
     <string name="nc_call_state_with_video">%1$s with video</string>
+    <string name="nc_missed_call">You missed a call from %s</string>
 
     <!-- Picture in Picture -->
     <string name="nc_pip_microphone_mute">Mute microphone</string>
@@ -246,7 +247,6 @@
     <string name="nc_share_subject">%1$s invitation</string>
     <string name="nc_share_text_pass">\nPassword: %1$s</string>
 
-    <!-- Magical stuff -->
     <string name="nc_push_to_talk">Push-to-talk</string>
     <string name="nc_push_to_talk_desc">With microphone disabled, click&amp;hold to use Push-to-talk</string>
     <string name="nc_configure_cert_auth">Select authentication certificate</string>