Răsfoiți Sursa

Merge pull request #1862 from nextcloud/bugfix/noid/kotlinConversion2

Migrate controllers to kotlin
Tim Krueger 3 ani în urmă
părinte
comite
d1d6898ffb
27 a modificat fișierele cu 1302 adăugiri și 1208 ștergeri
  1. 6 2
      app/src/androidTest/java/com/nextcloud/talk/utils/ShareUtilsIT.kt
  2. 95 84
      app/src/gplay/java/com/nextcloud/talk/services/firebase/MagicFirebaseMessagingService.kt
  3. 12 4
      app/src/gplay/java/com/nextcloud/talk/utils/ClosedInterfaceImpl.kt
  4. 51 43
      app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt
  5. 147 115
      app/src/main/java/com/nextcloud/talk/adapters/messages/MagicIncomingTextMessageViewHolder.kt
  6. 94 72
      app/src/main/java/com/nextcloud/talk/adapters/messages/MagicOutcomingTextMessageViewHolder.kt
  7. 40 28
      app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt
  8. 8 4
      app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt
  9. 9 4
      app/src/main/java/com/nextcloud/talk/controllers/AccountVerificationController.kt
  10. 29 15
      app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt
  11. 13 7
      app/src/main/java/com/nextcloud/talk/controllers/ConversationInfoController.kt
  12. 0 300
      app/src/main/java/com/nextcloud/talk/controllers/RingtoneSelectionController.java
  13. 257 0
      app/src/main/java/com/nextcloud/talk/controllers/RingtoneSelectionController.kt
  14. 2 1
      app/src/main/java/com/nextcloud/talk/controllers/ServerSelectionController.kt
  15. 1 1
      app/src/main/java/com/nextcloud/talk/controllers/SwitchAccountController.kt
  16. 0 504
      app/src/main/java/com/nextcloud/talk/controllers/WebViewLoginController.java
  17. 473 0
      app/src/main/java/com/nextcloud/talk/controllers/WebViewLoginController.kt
  18. 10 7
      app/src/main/java/com/nextcloud/talk/controllers/bottomsheet/EntryMenuController.kt
  19. 7 1
      app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt
  20. 9 4
      app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt
  21. 2 1
      app/src/main/java/com/nextcloud/talk/utils/AccountUtils.kt
  22. 30 0
      app/src/main/java/com/nextcloud/talk/utils/DateConstants.kt
  23. 3 7
      app/src/main/java/com/nextcloud/talk/utils/DateUtils.kt
  24. 1 1
      app/src/main/java/com/nextcloud/talk/utils/ssl/SSLSocketFactoryCompat.kt
  25. 1 1
      detekt.yml
  26. 1 1
      scripts/analysis/findbugs-results.txt
  27. 1 1
      scripts/analysis/lint-results.txt

+ 6 - 2
app/src/androidTest/java/com/nextcloud/talk/utils/ShareUtilsIT.kt

@@ -12,8 +12,8 @@ import java.util.Locale
 class ShareUtilsIT {
     @Test
     fun date() {
-        assertEquals(1207778138000, parseDate2("Mon, 09 Apr 2008 23:55:38 GMT").time)
-        assertEquals(1207778138000, HttpUtils.parseDate("Mon, 09 Apr 2008 23:55:38 GMT")?.time)
+        assertEquals(TEST_DATE_IN_MILLIS, parseDate2("Mon, 09 Apr 2008 23:55:38 GMT").time)
+        assertEquals(TEST_DATE_IN_MILLIS, HttpUtils.parseDate("Mon, 09 Apr 2008 23:55:38 GMT")?.time)
     }
 
     private fun parseDate2(dateStr: String): Date {
@@ -39,4 +39,8 @@ class ShareUtilsIT {
             "EEE MMM d yyyy HH:mm:ss z"
         )
     }
+
+    companion object {
+        private const val TEST_DATE_IN_MILLIS = 1207778138000
+    }
 }

+ 95 - 84
app/src/gplay/java/com/nextcloud/talk/services/firebase/MagicFirebaseMessagingService.kt

@@ -81,10 +81,6 @@ import javax.inject.Inject
 @SuppressLint("LongLogTag")
 @AutoInjector(NextcloudTalkApplication::class)
 class MagicFirebaseMessagingService : FirebaseMessagingService() {
-    companion object {
-        const val TAG = "MagicFirebaseMessagingService"
-    }
-
     @JvmField
     @Inject
     var appPreferences: AppPreferences? = null
@@ -162,84 +158,7 @@ class MagicFirebaseMessagingService : FirebaseMessagingService() {
                     base64DecodedSubject
                 )
                 if (signatureVerification!!.signatureValid) {
-                    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!!.userEntity,
-                                notificationId
-                            )
-                        } else if (deleteAll) {
-                            cancelAllNotificationsForAccount(applicationContext, signatureVerification!!.userEntity)
-                        } else if (deleteMultiple) {
-                            notificationIds!!.forEach {
-                                cancelExistingNotificationWithId(
-                                    applicationContext,
-                                    signatureVerification!!.userEntity,
-                                    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!!.userEntity)
-                            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@MagicFirebaseMessagingService,
-                                0,
-                                fullScreenIntent,
-                                PendingIntent.FLAG_UPDATE_CURRENT
-                            )
-
-                            val soundUri = getCallRingtoneUri(applicationContext!!, appPreferences!!)
-                            val notificationChannelId = NotificationUtils.NOTIFICATION_CHANNEL_CALLS_V4
-                            val uri = Uri.parse(signatureVerification!!.userEntity.baseUrl)
-                            val baseUrl = uri.host
-
-                            val notification =
-                                NotificationCompat.Builder(this@MagicFirebaseMessagingService, 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)
-                        }
-                    }
+                    decryptMessage(privateKey, base64DecodedSubject, subject, signature)
                 }
             } catch (e1: NoSuchAlgorithmException) {
                 Log.d(NotificationWorker.TAG, "No proper algorithm to decrypt the message " + e1.localizedMessage)
@@ -253,6 +172,92 @@ class MagicFirebaseMessagingService : FirebaseMessagingService() {
         }
     }
 
+    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!!.userEntity,
+                    notificationId
+                )
+            } else if (deleteAll) {
+                cancelAllNotificationsForAccount(applicationContext, signatureVerification!!.userEntity)
+            } else if (deleteMultiple) {
+                notificationIds!!.forEach {
+                    cancelExistingNotificationWithId(
+                        applicationContext,
+                        signatureVerification!!.userEntity,
+                        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!!.userEntity)
+                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@MagicFirebaseMessagingService,
+                    0,
+                    fullScreenIntent,
+                    PendingIntent.FLAG_UPDATE_CURRENT
+                )
+
+                val soundUri = getCallRingtoneUri(applicationContext!!, appPreferences!!)
+                val notificationChannelId = NotificationUtils.NOTIFICATION_CHANNEL_CALLS_V4
+                val uri = Uri.parse(signatureVerification!!.userEntity.baseUrl)
+                val baseUrl = uri.host
+
+                val notification =
+                    NotificationCompat.Builder(this@MagicFirebaseMessagingService, 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
@@ -279,8 +284,8 @@ class MagicFirebaseMessagingService : FirebaseMessagingService() {
             null
         )
             .repeatWhen { completed ->
-                completed.zipWith(Observable.range(1, 12), { _, i -> i })
-                    .flatMap { Observable.timer(5, TimeUnit.SECONDS) }
+                completed.zipWith(Observable.range(1, OBSERVABLE_COUNT), { _, i -> i })
+                    .flatMap { Observable.timer(OBSERVABLE_DELAY, TimeUnit.SECONDS) }
                     .takeWhile { isServiceInForeground && hasParticipantsInCall && !inCallOnDifferentDevice }
             }
             .subscribeOn(Schedulers.io())
@@ -318,4 +323,10 @@ class MagicFirebaseMessagingService : FirebaseMessagingService() {
                 }
             })
     }
+
+    companion object {
+        const val TAG = "MagicFirebaseMessagingService"
+        private const val OBSERVABLE_COUNT = 12
+        private const val OBSERVABLE_DELAY: Long = 5
+    }
 }

+ 12 - 4
app/src/gplay/java/com/nextcloud/talk/utils/ClosedInterfaceImpl.kt

@@ -2,9 +2,11 @@
  * Nextcloud Talk application
  *
  * @author Mario Danic
+ * @author Andy Scherzinger
  * @author Marcel Hibbe
  * Copyright (C) 2017-2019 Mario Danic <mario@lovelyhq.com>
  * Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
+ * Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
  *
  * 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
@@ -97,9 +99,9 @@ class ClosedInterfaceImpl : ClosedInterface, ProviderInstaller.ProviderInstallLi
 
         val periodicTokenRegistration = PeriodicWorkRequest.Builder(
             PushRegistrationWorker::class.java,
-            24,
+            DAILY,
             TimeUnit.HOURS,
-            10,
+            FLEX_INTERVAL,
             TimeUnit.HOURS
         )
             .setInputData(data)
@@ -115,9 +117,9 @@ class ClosedInterfaceImpl : ClosedInterface, ProviderInstaller.ProviderInstallLi
     private fun setUpPeriodicTokenRefreshFromFCM() {
         val periodicTokenRefreshFromFCM = PeriodicWorkRequest.Builder(
             GetFirebasePushTokenWorker::class.java,
-            30,
+            MONTHLY,
             TimeUnit.DAYS,
-            10,
+            FLEX_INTERVAL,
             TimeUnit.DAYS,
         )
             .build()
@@ -128,4 +130,10 @@ class ClosedInterfaceImpl : ClosedInterface, ProviderInstaller.ProviderInstallLi
                 periodicTokenRefreshFromFCM
             )
     }
+
+    companion object {
+        const val DAILY: Long = 24
+        const val MONTHLY: Long = 30
+        const val FLEX_INTERVAL: Long = 10
+    }
 }

+ 51 - 43
app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt

@@ -153,24 +153,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : Message
                     showVoiceMessageLoading()
                     WorkManager.getInstance(context!!).getWorkInfoByIdLiveData(workInfo.id)
                         .observeForever { info: WorkInfo? ->
-                            if (info != null) {
-                                when (info.state) {
-                                    WorkInfo.State.RUNNING -> {
-                                        Log.d(TAG, "WorkInfo.State.RUNNING in ViewHolder")
-                                        showVoiceMessageLoading()
-                                    }
-                                    WorkInfo.State.SUCCEEDED -> {
-                                        Log.d(TAG, "WorkInfo.State.SUCCEEDED in ViewHolder")
-                                        showPlayButton()
-                                    }
-                                    WorkInfo.State.FAILED -> {
-                                        Log.d(TAG, "WorkInfo.State.FAILED in ViewHolder")
-                                        showPlayButton()
-                                    }
-                                    else -> {
-                                    }
-                                }
-                            }
+                            showStatus(info)
                         }
                 }
             }
@@ -181,6 +164,27 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : Message
         }
     }
 
+    private fun showStatus(info: WorkInfo?) {
+        if (info != null) {
+            when (info.state) {
+                WorkInfo.State.RUNNING -> {
+                    Log.d(TAG, "WorkInfo.State.RUNNING in ViewHolder")
+                    showVoiceMessageLoading()
+                }
+                WorkInfo.State.SUCCEEDED -> {
+                    Log.d(TAG, "WorkInfo.State.SUCCEEDED in ViewHolder")
+                    showPlayButton()
+                }
+                WorkInfo.State.FAILED -> {
+                    Log.d(TAG, "WorkInfo.State.FAILED in ViewHolder")
+                    showPlayButton()
+                }
+                else -> {
+                }
+            }
+        }
+    }
+
     private fun showPlayButton() {
         binding.playPauseBtn.visibility = View.VISIBLE
         binding.progressBar.visibility = View.GONE
@@ -203,31 +207,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : Message
         }
 
         if (!message.isGrouped && !message.isOneToOneConversation) {
-            binding.messageUserAvatar.visibility = View.VISIBLE
-            if (message.actorType == "guests") {
-                // do nothing, avatar is set
-            } else if (message.actorType == "bots" && message.actorId == "changelog") {
-                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-                    val layers = arrayOfNulls<Drawable>(2)
-                    layers[0] = ContextCompat.getDrawable(context!!, R.drawable.ic_launcher_background)
-                    layers[1] = ContextCompat.getDrawable(context!!, R.drawable.ic_launcher_foreground)
-                    val layerDrawable = LayerDrawable(layers)
-                    binding.messageUserAvatar.setImageDrawable(DisplayUtils.getRoundedDrawable(layerDrawable))
-                } else {
-                    binding.messageUserAvatar.setImageResource(R.mipmap.ic_launcher)
-                }
-            } else if (message.actorType == "bots") {
-                val drawable = TextDrawable.builder()
-                    .beginConfig()
-                    .bold()
-                    .endConfig()
-                    .buildRound(
-                        ">",
-                        ResourcesCompat.getColor(context!!.resources, R.color.black, null)
-                    )
-                binding.messageUserAvatar.visibility = View.VISIBLE
-                binding.messageUserAvatar.setImageDrawable(drawable)
-            }
+            setAvatarOnMessage(message)
         } else {
             if (message.isOneToOneConversation) {
                 binding.messageUserAvatar.visibility = View.GONE
@@ -238,6 +218,34 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : Message
         }
     }
 
+    private fun setAvatarOnMessage(message: ChatMessage) {
+        binding.messageUserAvatar.visibility = View.VISIBLE
+        if (message.actorType == "guests") {
+            // do nothing, avatar is set
+        } else if (message.actorType == "bots" && message.actorId == "changelog") {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                val layers = arrayOfNulls<Drawable>(2)
+                layers[0] = ContextCompat.getDrawable(context!!, R.drawable.ic_launcher_background)
+                layers[1] = ContextCompat.getDrawable(context!!, R.drawable.ic_launcher_foreground)
+                val layerDrawable = LayerDrawable(layers)
+                binding.messageUserAvatar.setImageDrawable(DisplayUtils.getRoundedDrawable(layerDrawable))
+            } else {
+                binding.messageUserAvatar.setImageResource(R.mipmap.ic_launcher)
+            }
+        } else if (message.actorType == "bots") {
+            val drawable = TextDrawable.builder()
+                .beginConfig()
+                .bold()
+                .endConfig()
+                .buildRound(
+                    ">",
+                    ResourcesCompat.getColor(context!!.resources, R.color.black, null)
+                )
+            binding.messageUserAvatar.visibility = View.VISIBLE
+            binding.messageUserAvatar.setImageDrawable(drawable)
+        }
+    }
+
     private fun colorizeMessageBubble(message: ChatMessage) {
         val resources = itemView.resources
 

+ 147 - 115
app/src/main/java/com/nextcloud/talk/adapters/messages/MagicIncomingTextMessageViewHolder.kt

@@ -26,6 +26,7 @@ package com.nextcloud.talk.adapters.messages
 
 import android.content.Context
 import android.content.Intent
+import android.content.res.Resources
 import android.graphics.drawable.Drawable
 import android.graphics.drawable.LayerDrawable
 import android.net.Uri
@@ -53,6 +54,7 @@ import com.nextcloud.talk.utils.DisplayUtils
 import com.nextcloud.talk.utils.TextMatchers
 import com.nextcloud.talk.utils.preferences.AppPreferences
 import com.stfalcon.chatkit.messages.MessageHolders
+import java.util.HashMap
 import javax.inject.Inject
 
 @AutoInjector(NextcloudTalkApplication::class)
@@ -72,44 +74,10 @@ class MagicIncomingTextMessageViewHolder(itemView: View, payload: Any) : Message
     override fun onBind(message: ChatMessage) {
         super.onBind(message)
         sharedApplication!!.componentApplication.inject(this)
-        val author: String = message.actorDisplayName
-        if (!TextUtils.isEmpty(author)) {
-            binding.messageAuthor.text = author
-            binding.messageUserAvatar.setOnClickListener {
-                (payload as? ProfileBottomSheet)?.showFor(message.actorId, itemView.context)
-            }
-        } else {
-            binding.messageAuthor.setText(R.string.nc_nick_guest)
-        }
+        processAuthor(message)
 
         if (!message.isGrouped && !message.isOneToOneConversation) {
-            binding.messageUserAvatar.visibility = View.VISIBLE
-            if (message.actorType == "guests") {
-                // do nothing, avatar is set
-            } else if (message.actorType == "bots" && message.actorId == "changelog") {
-                if (context != null) {
-                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-                        val layers = arrayOfNulls<Drawable>(2)
-                        layers[0] = ContextCompat.getDrawable(context!!, R.drawable.ic_launcher_background)
-                        layers[1] = ContextCompat.getDrawable(context!!, R.drawable.ic_launcher_foreground)
-                        val layerDrawable = LayerDrawable(layers)
-                        binding.messageUserAvatar.setImageDrawable(DisplayUtils.getRoundedDrawable(layerDrawable))
-                    } else {
-                        binding.messageUserAvatar.setImageResource(R.mipmap.ic_launcher)
-                    }
-                }
-            } else if (message.actorType == "bots") {
-                val drawable = TextDrawable.builder()
-                    .beginConfig()
-                    .bold()
-                    .endConfig()
-                    .buildRound(
-                        ">",
-                        ResourcesCompat.getColor(context!!.resources, R.color.black, null)
-                    )
-                binding.messageUserAvatar.visibility = View.VISIBLE
-                binding.messageUserAvatar.setImageDrawable(drawable)
-            }
+            showAvatarOnChatMessage(message)
         } else {
             if (message.isOneToOneConversation) {
                 binding.messageUserAvatar.visibility = View.GONE
@@ -121,6 +89,53 @@ class MagicIncomingTextMessageViewHolder(itemView: View, payload: Any) : Message
 
         val resources = itemView.resources
 
+        setBubbleOnChatMessage(message, resources)
+
+        itemView.isSelected = false
+        binding.messageTime.setTextColor(ResourcesCompat.getColor(resources, R.color.warm_grey_four, null))
+
+        var messageString: Spannable = SpannableString(message.text)
+
+        var textSize = context?.resources!!.getDimension(R.dimen.chat_text_size)
+
+        val messageParameters = message.messageParameters
+        if (messageParameters != null && messageParameters.size > 0) {
+            messageString = processMessageParameters(messageParameters, message, messageString)
+        } else if (TextMatchers.isMessageWithSingleEmoticonOnly(message.text)) {
+            textSize = (textSize * TEXT_SIZE_MULTIPLIER).toFloat()
+            itemView.isSelected = true
+            binding.messageAuthor.visibility = View.GONE
+        }
+
+        binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
+        binding.messageText.text = messageString
+
+        // parent message handling
+        if (!message.isDeleted && message.parentMessage != null) {
+            processParentMessage(message)
+            binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
+        } else {
+            binding.messageQuote.quotedChatMessageView.visibility = View.GONE
+        }
+
+        itemView.setTag(MessageSwipeCallback.REPLYABLE_VIEW_TAG, message.isReplyable)
+    }
+
+    private fun processAuthor(message: ChatMessage) {
+        if (!TextUtils.isEmpty(message.actorDisplayName)) {
+            binding.messageAuthor.text = message.actorDisplayName
+            binding.messageUserAvatar.setOnClickListener {
+                (payload as? ProfileBottomSheet)?.showFor(message.actorId, itemView.context)
+            }
+        } else {
+            binding.messageAuthor.setText(R.string.nc_nick_guest)
+        }
+    }
+
+    private fun setBubbleOnChatMessage(
+        message: ChatMessage,
+        resources: Resources
+    ) {
         val bgBubbleColor = if (message.isDeleted) {
             ResourcesCompat.getColor(resources, R.color.bg_message_list_incoming_bubble_deleted, null)
         } else {
@@ -139,96 +154,113 @@ class MagicIncomingTextMessageViewHolder(itemView: View, payload: Any) : Message
             bgBubbleColor, bubbleResource
         )
         ViewCompat.setBackground(bubble, bubbleDrawable)
+    }
 
-        val messageParameters = message.messageParameters
-
-        itemView.isSelected = false
-        binding.messageTime.setTextColor(ResourcesCompat.getColor(resources, R.color.warm_grey_four, null))
+    private fun processParentMessage(message: ChatMessage) {
+        val parentChatMessage = message.parentMessage
+        parentChatMessage.activeUser = message.activeUser
+        parentChatMessage.imageUrl?.let {
+            binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
+            binding.messageQuote.quotedMessageImage.load(it) {
+                addHeader(
+                    "Authorization",
+                    ApiUtils.getCredentials(message.activeUser.username, message.activeUser.token)
+                )
+            }
+        } ?: run {
+            binding.messageQuote.quotedMessageImage.visibility = View.GONE
+        }
+        binding.messageQuote.quotedMessageAuthor.text = if (parentChatMessage.actorDisplayName.isNullOrEmpty())
+            context!!.getText(R.string.nc_nick_guest) else parentChatMessage.actorDisplayName
+        binding.messageQuote.quotedMessage.text = parentChatMessage.text
 
-        var messageString: Spannable = SpannableString(message.text)
+        binding.messageQuote.quotedMessageAuthor
+            .setTextColor(ContextCompat.getColor(context!!, R.color.textColorMaxContrast))
 
-        var textSize = context?.resources!!.getDimension(R.dimen.chat_text_size)
+        if (parentChatMessage.actorId?.equals(message.activeUser.userId) == true) {
+            binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.colorPrimary)
+        } else {
+            binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.textColorMaxContrast)
+        }
+    }
 
-        if (messageParameters != null && messageParameters.size > 0) {
-            for (key in messageParameters.keys) {
-                val individualHashMap = message.messageParameters[key]
-                if (individualHashMap != null) {
-                    if (
-                        individualHashMap["type"] == "user" ||
-                        individualHashMap["type"] == "guest" ||
-                        individualHashMap["type"] == "call"
-                    ) {
-                        if (individualHashMap["id"] == message.activeUser!!.userId) {
-                            messageString = DisplayUtils.searchAndReplaceWithMentionSpan(
-                                binding.messageText.context,
-                                messageString,
-                                individualHashMap["id"]!!,
-                                individualHashMap["name"]!!,
-                                individualHashMap["type"]!!,
-                                message.activeUser!!,
-                                R.xml.chip_you
-                            )
-                        } else {
-                            messageString = DisplayUtils.searchAndReplaceWithMentionSpan(
-                                binding.messageText.context,
-                                messageString,
-                                individualHashMap["id"]!!,
-                                individualHashMap["name"]!!,
-                                individualHashMap["type"]!!,
-                                message.activeUser!!,
-                                R.xml.chip_others
-                            )
-                        }
-                    } else if (individualHashMap["type"] == "file") {
-                        itemView.setOnClickListener { v ->
-                            val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(individualHashMap["link"]))
-                            context!!.startActivity(browserIntent)
-                        }
-                    }
+    private fun showAvatarOnChatMessage(message: ChatMessage) {
+        binding.messageUserAvatar.visibility = View.VISIBLE
+        if (message.actorType == "guests") {
+            // do nothing, avatar is set
+        } else if (message.actorType == "bots" && message.actorId == "changelog") {
+            if (context != null) {
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                    val layers = arrayOfNulls<Drawable>(2)
+                    layers[0] = ContextCompat.getDrawable(context!!, R.drawable.ic_launcher_background)
+                    layers[1] = ContextCompat.getDrawable(context!!, R.drawable.ic_launcher_foreground)
+                    val layerDrawable = LayerDrawable(layers)
+                    binding.messageUserAvatar.setImageDrawable(DisplayUtils.getRoundedDrawable(layerDrawable))
+                } else {
+                    binding.messageUserAvatar.setImageResource(R.mipmap.ic_launcher)
                 }
             }
-        } else if (TextMatchers.isMessageWithSingleEmoticonOnly(message.text)) {
-            textSize = (textSize * 2.5).toFloat()
-            itemView.isSelected = true
-            binding.messageAuthor.visibility = View.GONE
+        } else if (message.actorType == "bots") {
+            val drawable = TextDrawable.builder()
+                .beginConfig()
+                .bold()
+                .endConfig()
+                .buildRound(
+                    ">",
+                    ResourcesCompat.getColor(context!!.resources, R.color.black, null)
+                )
+            binding.messageUserAvatar.visibility = View.VISIBLE
+            binding.messageUserAvatar.setImageDrawable(drawable)
         }
+    }
 
-        binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
-        binding.messageText.text = messageString
-
-        // parent message handling
-        if (!message.isDeleted && message.parentMessage != null) {
-            val parentChatMessage = message.parentMessage
-            parentChatMessage.activeUser = message.activeUser
-            parentChatMessage.imageUrl?.let {
-                binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
-                binding.messageQuote.quotedMessageImage.load(it) {
-                    addHeader(
-                        "Authorization",
-                        ApiUtils.getCredentials(message.activeUser.username, message.activeUser.token)
-                    )
+    private fun processMessageParameters(
+        messageParameters: HashMap<String, HashMap<String, String>>,
+        message: ChatMessage,
+        messageString: Spannable
+    ): Spannable {
+        var messageStringInternal = messageString
+        for (key in messageParameters.keys) {
+            val individualHashMap = message.messageParameters[key]
+            if (individualHashMap != null) {
+                if (
+                    individualHashMap["type"] == "user" ||
+                    individualHashMap["type"] == "guest" ||
+                    individualHashMap["type"] == "call"
+                ) {
+                    if (individualHashMap["id"] == message.activeUser!!.userId) {
+                        messageStringInternal = DisplayUtils.searchAndReplaceWithMentionSpan(
+                            binding.messageText.context,
+                            messageStringInternal,
+                            individualHashMap["id"]!!,
+                            individualHashMap["name"]!!,
+                            individualHashMap["type"]!!,
+                            message.activeUser!!,
+                            R.xml.chip_you
+                        )
+                    } else {
+                        messageStringInternal = DisplayUtils.searchAndReplaceWithMentionSpan(
+                            binding.messageText.context,
+                            messageStringInternal,
+                            individualHashMap["id"]!!,
+                            individualHashMap["name"]!!,
+                            individualHashMap["type"]!!,
+                            message.activeUser!!,
+                            R.xml.chip_others
+                        )
+                    }
+                } else if (individualHashMap["type"] == "file") {
+                    itemView.setOnClickListener { v ->
+                        val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(individualHashMap["link"]))
+                        context!!.startActivity(browserIntent)
+                    }
                 }
-            } ?: run {
-                binding.messageQuote.quotedMessageImage.visibility = View.GONE
             }
-            binding.messageQuote.quotedMessageAuthor.text = if (parentChatMessage.actorDisplayName.isNullOrEmpty())
-                context!!.getText(R.string.nc_nick_guest) else parentChatMessage.actorDisplayName
-            binding.messageQuote.quotedMessage.text = parentChatMessage.text
-
-            binding.messageQuote.quotedMessageAuthor
-                .setTextColor(ContextCompat.getColor(context!!, R.color.textColorMaxContrast))
-
-            if (parentChatMessage.actorId?.equals(message.activeUser.userId) == true) {
-                binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.colorPrimary)
-            } else {
-                binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.textColorMaxContrast)
-            }
-
-            binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
-        } else {
-            binding.messageQuote.quotedChatMessageView.visibility = View.GONE
         }
+        return messageStringInternal
+    }
 
-        itemView.setTag(MessageSwipeCallback.REPLYABLE_VIEW_TAG, message.isReplyable)
+    companion object {
+        const val TEXT_SIZE_MULTIPLIER = 2.5
     }
 }

+ 94 - 72
app/src/main/java/com/nextcloud/talk/adapters/messages/MagicOutcomingTextMessageViewHolder.kt

@@ -72,91 +72,25 @@ class MagicOutcomingTextMessageViewHolder(itemView: View) : OutcomingTextMessage
         layoutParams.isWrapBefore = false
         var textSize = context!!.resources.getDimension(R.dimen.chat_text_size)
         if (messageParameters != null && messageParameters.size > 0) {
-            for (key in messageParameters.keys) {
-                val individualHashMap: HashMap<String, String>? = message.messageParameters[key]
-                if (individualHashMap != null) {
-                    if (individualHashMap["type"] == "user" ||
-                        individualHashMap["type"] == "guest" ||
-                        individualHashMap["type"] == "call"
-                    ) {
-                        messageString = searchAndReplaceWithMentionSpan(
-                            binding.messageText.context,
-                            messageString,
-                            individualHashMap["id"]!!,
-                            individualHashMap["name"]!!,
-                            individualHashMap["type"]!!,
-                            message.activeUser,
-                            R.xml.chip_others
-                        )
-                    } else if (individualHashMap["type"] == "file") {
-                        realView.setOnClickListener { v: View? ->
-                            val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(individualHashMap["link"]))
-                            context!!.startActivity(browserIntent)
-                        }
-                    }
-                }
-            }
+            messageString = processMessageParameters(messageParameters, message, messageString)
         } else if (TextMatchers.isMessageWithSingleEmoticonOnly(message.text)) {
-            textSize = (textSize * 2.5).toFloat()
+            textSize = (textSize * TEXT_SIZE_MULTIPLIER).toFloat()
             layoutParams.isWrapBefore = true
             binding.messageTime.setTextColor(
                 ResourcesCompat.getColor(context!!.resources, R.color.warm_grey_four, null)
             )
             realView.isSelected = true
         }
-        val resources = sharedApplication!!.resources
-        val bgBubbleColor = if (message.isDeleted) {
-            ResourcesCompat.getColor(resources, R.color.bg_message_list_outcoming_bubble_deleted, null)
-        } else {
-            ResourcesCompat.getColor(resources, R.color.bg_message_list_outcoming_bubble, null)
-        }
-        if (message.isGrouped) {
-            val bubbleDrawable = getMessageSelector(
-                bgBubbleColor,
-                ResourcesCompat.getColor(resources, R.color.transparent, null),
-                bgBubbleColor,
-                R.drawable.shape_grouped_outcoming_message
-            )
-            ViewCompat.setBackground(bubble, bubbleDrawable)
-        } else {
-            val bubbleDrawable = getMessageSelector(
-                bgBubbleColor,
-                ResourcesCompat.getColor(resources, R.color.transparent, null),
-                bgBubbleColor,
-                R.drawable.shape_outcoming_message
-            )
-            ViewCompat.setBackground(bubble, bubbleDrawable)
-        }
+
+        setBubbleOnChatMessage(message)
+
         binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
         binding.messageTime.layoutParams = layoutParams
         binding.messageText.text = messageString
 
         // parent message handling
-
         if (!message.isDeleted && message.parentMessage != null) {
-            val parentChatMessage = message.parentMessage
-            parentChatMessage.activeUser = message.activeUser
-            parentChatMessage.imageUrl?.let {
-                binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
-                binding.messageQuote.quotedMessageImage.load(it) {
-                    addHeader(
-                        "Authorization",
-                        ApiUtils.getCredentials(message.activeUser.username, message.activeUser.token)
-                    )
-                }
-            } ?: run {
-                binding.messageQuote.quotedMessageImage.visibility = View.GONE
-            }
-            binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
-                ?: context!!.getText(R.string.nc_nick_guest)
-            binding.messageQuote.quotedMessage.text = parentChatMessage.text
-            binding.messageQuote.quotedMessage.setTextColor(
-                ContextCompat.getColor(context!!, R.color.nc_outcoming_text_default)
-            )
-            binding.messageQuote.quotedMessageAuthor.setTextColor(ContextCompat.getColor(context!!, R.color.nc_grey))
-
-            binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.white)
-
+            processParentMessage(message)
             binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
         } else {
             binding.messageQuote.quotedChatMessageView.visibility = View.GONE
@@ -185,4 +119,92 @@ class MagicOutcomingTextMessageViewHolder(itemView: View) : OutcomingTextMessage
 
         itemView.setTag(MessageSwipeCallback.REPLYABLE_VIEW_TAG, message.isReplyable)
     }
+
+    private fun processParentMessage(message: ChatMessage) {
+        val parentChatMessage = message.parentMessage
+        parentChatMessage.activeUser = message.activeUser
+        parentChatMessage.imageUrl?.let {
+            binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
+            binding.messageQuote.quotedMessageImage.load(it) {
+                addHeader(
+                    "Authorization",
+                    ApiUtils.getCredentials(message.activeUser.username, message.activeUser.token)
+                )
+            }
+        } ?: run {
+            binding.messageQuote.quotedMessageImage.visibility = View.GONE
+        }
+        binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
+            ?: context!!.getText(R.string.nc_nick_guest)
+        binding.messageQuote.quotedMessage.text = parentChatMessage.text
+        binding.messageQuote.quotedMessage.setTextColor(
+            ContextCompat.getColor(context!!, R.color.nc_outcoming_text_default)
+        )
+        binding.messageQuote.quotedMessageAuthor.setTextColor(ContextCompat.getColor(context!!, R.color.nc_grey))
+
+        binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.white)
+    }
+
+    private fun setBubbleOnChatMessage(message: ChatMessage) {
+        val resources = sharedApplication!!.resources
+        val bgBubbleColor = if (message.isDeleted) {
+            ResourcesCompat.getColor(resources, R.color.bg_message_list_outcoming_bubble_deleted, null)
+        } else {
+            ResourcesCompat.getColor(resources, R.color.bg_message_list_outcoming_bubble, null)
+        }
+        if (message.isGrouped) {
+            val bubbleDrawable = getMessageSelector(
+                bgBubbleColor,
+                ResourcesCompat.getColor(resources, R.color.transparent, null),
+                bgBubbleColor,
+                R.drawable.shape_grouped_outcoming_message
+            )
+            ViewCompat.setBackground(bubble, bubbleDrawable)
+        } else {
+            val bubbleDrawable = getMessageSelector(
+                bgBubbleColor,
+                ResourcesCompat.getColor(resources, R.color.transparent, null),
+                bgBubbleColor,
+                R.drawable.shape_outcoming_message
+            )
+            ViewCompat.setBackground(bubble, bubbleDrawable)
+        }
+    }
+
+    private fun processMessageParameters(
+        messageParameters: HashMap<String, HashMap<String, String>>,
+        message: ChatMessage,
+        messageString: Spannable
+    ): Spannable {
+        var messageString1 = messageString
+        for (key in messageParameters.keys) {
+            val individualHashMap: HashMap<String, String>? = message.messageParameters[key]
+            if (individualHashMap != null) {
+                if (individualHashMap["type"] == "user" ||
+                    individualHashMap["type"] == "guest" ||
+                    individualHashMap["type"] == "call"
+                ) {
+                    messageString1 = searchAndReplaceWithMentionSpan(
+                        binding.messageText.context,
+                        messageString1,
+                        individualHashMap["id"]!!,
+                        individualHashMap["name"]!!,
+                        individualHashMap["type"]!!,
+                        message.activeUser,
+                        R.xml.chip_others
+                    )
+                } else if (individualHashMap["type"] == "file") {
+                    realView.setOnClickListener { v: View? ->
+                        val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(individualHashMap["link"]))
+                        context!!.startActivity(browserIntent)
+                    }
+                }
+            }
+        }
+        return messageString1
+    }
+
+    companion object {
+        const val TEXT_SIZE_MULTIPLIER = 2.5
+    }
 }

+ 40 - 28
app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt

@@ -87,36 +87,11 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : MessageHolders
         updateDownloadState(message)
         binding.seekbar.max = message.voiceMessageDuration
 
-        if (message.isPlayingVoiceMessage) {
-            showPlayButton()
-            binding.playPauseBtn.icon = ContextCompat.getDrawable(
-                context!!,
-                R.drawable.ic_baseline_pause_voice_message_24
-            )
-            binding.seekbar.progress = message.voiceMessagePlayedSeconds
-        } else {
-            binding.playPauseBtn.visibility = View.VISIBLE
-            binding.playPauseBtn.icon = ContextCompat.getDrawable(
-                context!!,
-                R.drawable.ic_baseline_play_arrow_voice_message_24
-            )
-        }
+        handleIsPlayingVoiceMessageState(message)
 
-        if (message.isDownloadingVoiceMessage) {
-            showVoiceMessageLoading()
-        } else {
-            binding.progressBar.visibility = View.GONE
-        }
+        handleIsDownloadingVoiceMessageState(message)
 
-        if (message.resetVoiceMessage) {
-            binding.playPauseBtn.visibility = View.VISIBLE
-            binding.playPauseBtn.icon = ContextCompat.getDrawable(
-                context!!,
-                R.drawable.ic_baseline_play_arrow_voice_message_24
-            )
-            binding.seekbar.progress = SEEKBAR_START
-            message.resetVoiceMessage = false
-        }
+        handleResetVoiceMessageState(message)
 
         binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
             override fun onStopTrackingTouch(seekBar: SeekBar) {
@@ -156,6 +131,43 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : MessageHolders
         binding.checkMark.setContentDescription(readStatusContentDescriptionString)
     }
 
+    private fun handleResetVoiceMessageState(message: ChatMessage) {
+        if (message.resetVoiceMessage) {
+            binding.playPauseBtn.visibility = View.VISIBLE
+            binding.playPauseBtn.icon = ContextCompat.getDrawable(
+                context!!,
+                R.drawable.ic_baseline_play_arrow_voice_message_24
+            )
+            binding.seekbar.progress = SEEKBAR_START
+            message.resetVoiceMessage = false
+        }
+    }
+
+    private fun handleIsDownloadingVoiceMessageState(message: ChatMessage) {
+        if (message.isDownloadingVoiceMessage) {
+            showVoiceMessageLoading()
+        } else {
+            binding.progressBar.visibility = View.GONE
+        }
+    }
+
+    private fun handleIsPlayingVoiceMessageState(message: ChatMessage) {
+        if (message.isPlayingVoiceMessage) {
+            showPlayButton()
+            binding.playPauseBtn.icon = ContextCompat.getDrawable(
+                context!!,
+                R.drawable.ic_baseline_pause_voice_message_24
+            )
+            binding.seekbar.progress = message.voiceMessagePlayedSeconds
+        } else {
+            binding.playPauseBtn.visibility = View.VISIBLE
+            binding.playPauseBtn.icon = ContextCompat.getDrawable(
+                context!!,
+                R.drawable.ic_baseline_play_arrow_voice_message_24
+            )
+        }
+    }
+
     private fun updateDownloadState(message: ChatMessage) {
         // check if download worker is already running
         val fileId = message.getSelectedIndividualHashMap()["id"]

+ 8 - 4
app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt

@@ -2,10 +2,12 @@
  *
  *   Nextcloud Talk application
  *
- * @author Mario Danic
  * @author Marcel Hibbe
- * Copyright (C) 2017 Mario Danic <mario@lovelyhq.com>
+ * @author Andy Scherzinger
+ * @author Mario Danic
  * Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
+ * Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
+ * Copyright (C) 2017 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
@@ -170,7 +172,7 @@ class NextcloudTalkApplication : MultiDexApplication(), LifecycleObserver {
         val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build()
         val periodicCapabilitiesUpdateWork = PeriodicWorkRequest.Builder(
             CapabilitiesWorker::class.java,
-            12, TimeUnit.HOURS
+            HALF_DAY, TimeUnit.HOURS
         ).build()
         val capabilitiesUpdateWork = OneTimeWorkRequest.Builder(CapabilitiesWorker::class.java).build()
         val signalingSettingsWork = OneTimeWorkRequest.Builder(SignalingSettingsWorker::class.java).build()
@@ -218,7 +220,7 @@ class NextcloudTalkApplication : MultiDexApplication(), LifecycleObserver {
 
     private fun buildDefaultImageLoader(): ImageLoader {
         return ImageLoader.Builder(applicationContext)
-            .availableMemoryPercentage(0.5) // Use 50% of the application's available memory.
+            .availableMemoryPercentage(FIFTY_PERCENT) // Use 50% of the application's available memory.
             .crossfade(true) // Show a short crossfade when loading images from network or disk into an ImageView.
             .componentRegistry {
                 if (SDK_INT >= P) {
@@ -234,6 +236,8 @@ class NextcloudTalkApplication : MultiDexApplication(), LifecycleObserver {
 
     companion object {
         private val TAG = NextcloudTalkApplication::class.java.simpleName
+        const val FIFTY_PERCENT = 0.5
+        const val HALF_DAY: Long = 12
         //region Singleton
         //endregion
 

+ 9 - 4
app/src/main/java/com/nextcloud/talk/controllers/AccountVerificationController.kt

@@ -2,6 +2,8 @@
  * Nextcloud Talk application
  *
  * @author Mario Danic
+ * @author Andy Scherzinger
+ * Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
  * Copyright (C) 2017 Mario Danic (mario@lovelyhq.com)
  *
  * This program is free software: you can redistribute it and/or modify
@@ -161,7 +163,9 @@ class AccountVerificationController(args: Bundle? = null) :
                             RouterTransaction.with(
                                 WebViewLoginController(
                                     baseUrl,
-                                    false, username, ""
+                                    false,
+                                    username,
+                                    ""
                                 )
                             )
                                 .pushChangeHandler(HorizontalChangeHandler())
@@ -473,7 +477,7 @@ class AccountVerificationController(args: Bundle? = null) :
                         // unused atm
                     }
                     override fun onComplete() {
-                        activity?.runOnUiThread { Handler().postDelayed({ router.popToRoot() }, 7500) }
+                        activity?.runOnUiThread { Handler().postDelayed({ router.popToRoot() }, DELAY_IN_MILLIS) }
                     }
 
                     override fun onError(e: Throwable) {
@@ -481,7 +485,7 @@ class AccountVerificationController(args: Bundle? = null) :
                     }
                 })
             } else {
-                activity?.runOnUiThread { Handler().postDelayed({ router.popToRoot() }, 7500) }
+                activity?.runOnUiThread { Handler().postDelayed({ router.popToRoot() }, DELAY_IN_MILLIS) }
             }
         } else {
             ApplicationWideMessageHolder.getInstance().setMessageType(
@@ -508,13 +512,14 @@ class AccountVerificationController(args: Bundle? = null) :
                             )
                         }
                     }
-                }, 7500)
+                }, DELAY_IN_MILLIS)
             }
         }
     }
 
     companion object {
         const val TAG = "AccountVerificationController"
+        const val DELAY_IN_MILLIS: Long = 7500
     }
 
     init {

+ 29 - 15
app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt

@@ -423,7 +423,7 @@ class ChatController(args: Bundle) :
                     override fun onNewResultImpl(bitmap: Bitmap?) {
                         if (actionBar != null && bitmap != null && resources != null) {
 
-                            val avatarSize = (actionBar?.height!! / 1.5).roundToInt()
+                            val avatarSize = (actionBar?.height!! / TOOLBAR_AVATAR_RATIO).roundToInt()
                             if (avatarSize > 0) {
                                 val bitmapResized = Bitmap.createScaledBitmap(bitmap, avatarSize, avatarSize, false)
 
@@ -1043,7 +1043,7 @@ class ChatController(args: Bundle) :
         binding.messageInputView.audioRecordDuration.start()
 
         val animation: Animation = AlphaAnimation(1.0f, 0.0f)
-        animation.duration = 750
+        animation.duration = ANIMATION_DURATION
         animation.interpolator = LinearInterpolator()
         animation.repeatCount = Animation.INFINITE
         animation.repeatMode = Animation.REVERSE
@@ -1494,7 +1494,7 @@ class ChatController(args: Bundle) :
 
     private fun setupMentionAutocomplete() {
         if (isAlive()) {
-            val elevation = 6f
+            val elevation = MENTION_AUTO_COMPLETE_ELEVATION
             resources?.let {
                 val backgroundDrawable = ColorDrawable(it.getColor(R.color.bg_default))
                 val presenter = MentionAutocompletePresenter(activity, roomToken)
@@ -1691,7 +1691,7 @@ class ChatController(args: Bundle) :
             )
                 ?.subscribeOn(Schedulers.io())
                 ?.observeOn(AndroidSchedulers.mainThread())
-                ?.retry(3)
+                ?.retry(RETRIES)
                 ?.subscribe(object : Observer<RoomOverall> {
                     override fun onSubscribe(d: Disposable) {
                         disposableList.add(d)
@@ -1951,7 +1951,7 @@ class ChatController(args: Bundle) :
         }
 
         val timeout = if (lookingIntoFuture) {
-            30
+            LOOKING_INTO_FUTURE_TIMEOUT
         } else {
             0
         }
@@ -1959,7 +1959,7 @@ class ChatController(args: Bundle) :
         fieldMap["timeout"] = timeout
 
         fieldMap["lookIntoFuture"] = lookIntoFuture
-        fieldMap["limit"] = 100
+        fieldMap["limit"] = MESSAGE_PULL_LIMIT
         fieldMap["setReadMarker"] = setReadMarker
 
         val lastKnown: Int
@@ -1999,10 +1999,10 @@ class ChatController(args: Bundle) :
                         Log.d(TAG, "pullChatMessages - pullChatMessages[lookIntoFuture > 0] - got response")
                         pullChatMessagesPending = false
                         try {
-                            if (response.code() == 304) {
+                            if (response.code() == HTTP_CODE_NOT_MODIFIED) {
                                 Log.d(TAG, "pullChatMessages - quasi recursive call to pullChatMessages")
                                 pullChatMessages(1, setReadMarker, xChatLastCommonRead)
-                            } else if (response.code() == 412) {
+                            } else if (response.code() == HTTP_CODE_PRECONDITION_FAILED) {
                                 futurePreconditionFailed = true
                             } else {
                                 processMessages(response, true, finalTimeout)
@@ -2041,7 +2041,7 @@ class ChatController(args: Bundle) :
                         Log.d(TAG, "pullChatMessages - pullChatMessages[lookIntoFuture <= 0] - got response")
                         pullChatMessagesPending = false
                         try {
-                            if (response.code() == 412) {
+                            if (response.code() == HTTP_CODE_PRECONDITION_FAILED) {
                                 pastPreconditionFailed = true
                             } else {
                                 processMessages(response, false, 0)
@@ -2129,7 +2129,7 @@ class ChatController(args: Bundle) :
                     if (chatMessageList.size > i + 1) {
                         if (isSameDayNonSystemMessages(chatMessageList[i], chatMessageList[i + 1]) &&
                             chatMessageList[i + 1].actorId == chatMessageList[i].actorId &&
-                            countGroupedMessages < 4
+                            countGroupedMessages < GROUPED_MESSAGES_THRESHOLD
                         ) {
                             chatMessageList[i].isGrouped = true
                             countGroupedMessages++
@@ -2210,7 +2210,8 @@ class ChatController(args: Bundle) :
                             adapter!!.isPreviousSameAuthor(
                                 chatMessage.actorId,
                                 -1
-                            ) && adapter!!.getSameAuthorLastMessagesCount(chatMessage.actorId) % 5 > 0
+                            ) && adapter!!.getSameAuthorLastMessagesCount(chatMessage.actorId) %
+                                GROUPED_MESSAGES_SAME_AUTHOR_THRESHOLD > 0
                             )
                         chatMessage.isOneToOneConversation =
                             (currentConversation?.type == Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL)
@@ -2245,7 +2246,7 @@ class ChatController(args: Bundle) :
             if (inConversation) {
                 pullChatMessages(1, 1, xChatLastCommonRead)
             }
-        } else if (response.code() == 304 && !isFromTheFuture) {
+        } else if (response.code() == HTTP_CODE_NOT_MODIFIED && !isFromTheFuture) {
             if (isFirstMessagesProcessing) {
                 cancelNotificationsForCurrentConversation()
 
@@ -2478,7 +2479,7 @@ class ChatController(args: Bundle) :
                             conversationUser?.baseUrl,
                             "1",
                             null,
-                            message?.user?.id?.substring(6),
+                            message?.user?.id?.substring(INVITE_LENGTH),
                             null
                         )
                         ncApi!!.createRoom(
@@ -2600,7 +2601,7 @@ class ChatController(args: Bundle) :
             menu.findItem(R.id.action_reply_privately).isVisible = message.replyable &&
                 conversationUser?.userId?.isNotEmpty() == true && conversationUser.userId != "?" &&
                 message.user.id.startsWith("users/") &&
-                message.user.id.substring(6) != currentConversation?.actorId &&
+                message.user.id.substring(ACTOR_LENGTH) != currentConversation?.actorId &&
                 currentConversation?.type != Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
             menu.findItem(R.id.action_delete_message).isVisible = isShowMessageDeletionButton(message)
             menu.findItem(R.id.action_forward_message).isVisible =
@@ -2642,7 +2643,7 @@ class ChatController(args: Bundle) :
 
                     val px = TypedValue.applyDimension(
                         TypedValue.COMPLEX_UNIT_DIP,
-                        96f,
+                        QUOTED_MESSAGE_IMAGE_MAX_HEIGHT,
                         resources?.displayMetrics
                     )
 
@@ -2860,5 +2861,18 @@ class ChatController(args: Bundle) :
         private const val VOICE_MESSAGE_SEEKBAR_BASE: Int = 1000
         private const val SECOND: Long = 1000
         private const val NO_PREVIOUS_MESSAGE_ID: Int = -1
+        private const val GROUPED_MESSAGES_THRESHOLD = 4
+        private const val GROUPED_MESSAGES_SAME_AUTHOR_THRESHOLD = 5
+        private const val TOOLBAR_AVATAR_RATIO = 1.5
+        private const val HTTP_CODE_NOT_MODIFIED = 304
+        private const val HTTP_CODE_PRECONDITION_FAILED = 412
+        private const val QUOTED_MESSAGE_IMAGE_MAX_HEIGHT = 96f
+        private const val MENTION_AUTO_COMPLETE_ELEVATION = 6f
+        private const val MESSAGE_PULL_LIMIT = 100
+        private const val INVITE_LENGTH = 6
+        private const val ACTOR_LENGTH = 6
+        private const val ANIMATION_DURATION: Long = 750
+        private const val RETRIES: Long = 3
+        private const val LOOKING_INTO_FUTURE_TIMEOUT = 30
     }
 }

+ 13 - 7
app/src/main/java/com/nextcloud/talk/controllers/ConversationInfoController.kt

@@ -72,6 +72,7 @@ import com.nextcloud.talk.models.json.participants.Participant.ActorType.GROUPS
 import com.nextcloud.talk.models.json.participants.Participant.ActorType.USERS
 import com.nextcloud.talk.models.json.participants.ParticipantsOverall
 import com.nextcloud.talk.utils.ApiUtils
+import com.nextcloud.talk.utils.DateConstants
 import com.nextcloud.talk.utils.DateUtils
 import com.nextcloud.talk.utils.DisplayUtils
 import com.nextcloud.talk.utils.bundle.BundleKeys
@@ -206,7 +207,7 @@ class ConversationInfoController(args: Bundle) :
                 MaterialDialog(activity!!, BottomSheet(WRAP_CONTENT)).show {
                     val currentTimeCalendar = Calendar.getInstance()
                     if (conversation!!.lobbyTimer != null && conversation!!.lobbyTimer != 0L) {
-                        currentTimeCalendar.timeInMillis = conversation!!.lobbyTimer * 1000
+                        currentTimeCalendar.timeInMillis = conversation!!.lobbyTimer * DateConstants.SECOND_DIVIDER
                     }
 
                     dateTimePicker(
@@ -238,13 +239,15 @@ class ConversationInfoController(args: Bundle) :
             conversation.type == Conversation.ConversationType.ROOM_PUBLIC_CALL
     }
 
-    fun reconfigureLobbyTimerView(dateTime: Calendar? = null) {
+    private fun reconfigureLobbyTimerView(dateTime: Calendar? = null) {
         val isChecked =
             (binding.webinarInfoView.conversationInfoLobby.findViewById<View>(R.id.mp_checkable) as SwitchCompat)
                 .isChecked
 
         if (dateTime != null && isChecked) {
-            conversation!!.lobbyTimer = (dateTime.timeInMillis - (dateTime.time.seconds * 1000)) / 1000
+            conversation!!.lobbyTimer = (
+                dateTime.timeInMillis - (dateTime.time.seconds * DateConstants.SECOND_DIVIDER)
+                ) / DateConstants.SECOND_DIVIDER
         } else if (!isChecked) {
             conversation!!.lobbyTimer = 0
         }
@@ -683,9 +686,9 @@ class ConversationInfoController(args: Bundle) :
                 if (conversation!!.notificationLevel != Conversation.NotificationLevel.DEFAULT) {
                     val stringValue: String =
                         when (EnumNotificationLevelConverter().convertToInt(conversation!!.notificationLevel)) {
-                            1 -> "always"
-                            2 -> "mention"
-                            3 -> "never"
+                            NOTIFICATION_LEVEL_ALWAYS -> "always"
+                            NOTIFICATION_LEVEL_MENTION -> "mention"
+                            NOTIFICATION_LEVEL_NEVER -> "never"
                             else -> "mention"
                         }
 
@@ -1100,7 +1103,10 @@ class ConversationInfoController(args: Bundle) :
     }
 
     companion object {
-        private const val TAG = "ConversationInfControll"
+        private const val TAG = "ConversationInfo"
+        private const val NOTIFICATION_LEVEL_ALWAYS: Int = 1
+        private const val NOTIFICATION_LEVEL_MENTION: Int = 2
+        private const val NOTIFICATION_LEVEL_NEVER: Int = 3
         private const val ID_DELETE_CONVERSATION_DIALOG = 0
         private const val ID_CLEAR_CHAT_DIALOG = 1
         private val LOW_EMPHASIS_OPACITY: Float = 0.38f

+ 0 - 300
app/src/main/java/com/nextcloud/talk/controllers/RingtoneSelectionController.java

@@ -1,300 +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.controllers;
-
-import android.annotation.SuppressLint;
-import android.content.Context;
-import android.database.Cursor;
-import android.media.MediaPlayer;
-import android.media.RingtoneManager;
-import android.net.Uri;
-import android.os.Bundle;
-import android.os.Handler;
-import android.text.TextUtils;
-import android.util.Log;
-import android.view.LayoutInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import androidx.annotation.NonNull;
-import androidx.recyclerview.widget.RecyclerView;
-import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
-import autodagger.AutoInjector;
-import butterknife.BindView;
-import com.bluelinelabs.logansquare.LoganSquare;
-import com.nextcloud.talk.R;
-import com.nextcloud.talk.adapters.items.NotificationSoundItem;
-import com.nextcloud.talk.application.NextcloudTalkApplication;
-import com.nextcloud.talk.controllers.base.BaseController;
-import com.nextcloud.talk.models.RingtoneSettings;
-import com.nextcloud.talk.utils.NotificationUtils;
-import com.nextcloud.talk.utils.bundle.BundleKeys;
-import com.nextcloud.talk.utils.preferences.AppPreferences;
-import eu.davidea.flexibleadapter.FlexibleAdapter;
-import eu.davidea.flexibleadapter.SelectableAdapter;
-import eu.davidea.flexibleadapter.common.SmoothScrollLinearLayoutManager;
-import eu.davidea.flexibleadapter.items.AbstractFlexibleItem;
-
-import javax.inject.Inject;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
-@AutoInjector(NextcloudTalkApplication.class)
-public class RingtoneSelectionController extends BaseController implements FlexibleAdapter.OnItemClickListener {
-
-    private static final String TAG = "RingtoneSelectionController";
-
-    @BindView(R.id.recycler_view)
-    RecyclerView recyclerView;
-
-    @BindView(R.id.swipe_refresh_layout)
-    SwipeRefreshLayout swipeRefreshLayout;
-
-    @Inject
-    AppPreferences appPreferences;
-
-    @Inject
-    Context context;
-
-    private FlexibleAdapter adapter;
-    private RecyclerView.AdapterDataObserver adapterDataObserver;
-    private List<AbstractFlexibleItem> abstractFlexibleItemList = new ArrayList<>();
-
-    private boolean callNotificationSounds;
-    private MediaPlayer mediaPlayer;
-    private Handler cancelMediaPlayerHandler;
-
-    public RingtoneSelectionController(Bundle args) {
-        super(args);
-        setHasOptionsMenu(true);
-        this.callNotificationSounds = args.getBoolean(BundleKeys.INSTANCE.getKEY_ARE_CALL_SOUNDS(), false);
-    }
-
-    @Override
-    protected View inflateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
-        return inflater.inflate(R.layout.controller_generic_rv, container, false);
-    }
-
-    @Override
-    protected void onViewBound(@NonNull View view) {
-        super.onViewBound(view);
-        NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this);
-
-        if (adapter == null) {
-            adapter = new FlexibleAdapter<>(abstractFlexibleItemList, getActivity(), false);
-
-            adapter.setNotifyChangeOfUnfilteredItems(true)
-                    .setMode(SelectableAdapter.Mode.SINGLE);
-
-            adapter.addListener(this);
-
-            cancelMediaPlayerHandler = new Handler();
-        }
-
-        adapter.addListener(this);
-        prepareViews();
-        fetchNotificationSounds();
-    }
-
-    @Override
-    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
-        if (item.getItemId() == android.R.id.home) {
-            return getRouter().popCurrentController();
-        }
-        return super.onOptionsItemSelected(item);
-    }
-
-    private void prepareViews() {
-        RecyclerView.LayoutManager layoutManager = new SmoothScrollLinearLayoutManager(getActivity());
-        recyclerView.setLayoutManager(layoutManager);
-        recyclerView.setHasFixedSize(true);
-        recyclerView.setAdapter(adapter);
-
-        adapterDataObserver = new RecyclerView.AdapterDataObserver() {
-            @Override
-            public void onChanged() {
-                super.onChanged();
-                findSelectedSound();
-            }
-        };
-
-        adapter.registerAdapterDataObserver(adapterDataObserver);
-        swipeRefreshLayout.setEnabled(false);
-    }
-
-    @SuppressLint("LongLogTag")
-    private void findSelectedSound() {
-        boolean foundDefault = false;
-
-        String preferencesString = null;
-        if ((callNotificationSounds && TextUtils.isEmpty((preferencesString = appPreferences.getCallRingtoneUri())))
-                || (!callNotificationSounds && TextUtils.isEmpty((preferencesString = appPreferences
-                .getMessageRingtoneUri())))) {
-            adapter.toggleSelection(1);
-            foundDefault = true;
-        }
-
-        if (!TextUtils.isEmpty(preferencesString) && !foundDefault) {
-            try {
-                RingtoneSettings ringtoneSettings = LoganSquare.parse(preferencesString, RingtoneSettings.class);
-                if (ringtoneSettings.getRingtoneUri() == null) {
-                    adapter.toggleSelection(0);
-                } else if (ringtoneSettings.getRingtoneUri().toString().equals(getRingtoneString())) {
-                    adapter.toggleSelection(1);
-                } else {
-                    NotificationSoundItem notificationSoundItem;
-                    for (int i = 2; i < adapter.getItemCount(); i++) {
-                        notificationSoundItem = (NotificationSoundItem) adapter.getItem(i);
-                        if (notificationSoundItem.getNotificationSoundUri().equals(ringtoneSettings.getRingtoneUri().toString())) {
-                            adapter.toggleSelection(i);
-                            break;
-                        }
-                    }
-                }
-            } catch (IOException e) {
-                Log.e(TAG, "Failed to parse ringtone settings");
-            }
-        }
-
-        adapter.unregisterAdapterDataObserver(adapterDataObserver);
-        adapterDataObserver = null;
-    }
-
-    private String getRingtoneString() {
-        if (callNotificationSounds) {
-            return NotificationUtils.DEFAULT_CALL_RINGTONE_URI;
-        } else {
-            return NotificationUtils.DEFAULT_MESSAGE_RINGTONE_URI;
-        }
-    }
-
-    private void fetchNotificationSounds() {
-        abstractFlexibleItemList.add(new NotificationSoundItem(getResources().getString(R.string.nc_settings_no_ringtone), null));
-        abstractFlexibleItemList.add(new NotificationSoundItem(getResources()
-                .getString(R.string.nc_settings_default_ringtone), getRingtoneString()));
-
-
-        if (getActivity() != null) {
-            RingtoneManager manager = new RingtoneManager(getActivity());
-
-            if (callNotificationSounds) {
-                manager.setType(RingtoneManager.TYPE_RINGTONE);
-            } else {
-                manager.setType(RingtoneManager.TYPE_NOTIFICATION);
-            }
-
-            Cursor cursor = manager.getCursor();
-
-            NotificationSoundItem notificationSoundItem;
-
-            while (cursor.moveToNext()) {
-                String notificationTitle = cursor.getString(RingtoneManager.TITLE_COLUMN_INDEX);
-                String notificationUri = cursor.getString(RingtoneManager.URI_COLUMN_INDEX);
-                String completeNotificationUri = notificationUri + "/" + cursor.getString(RingtoneManager
-                        .ID_COLUMN_INDEX);
-
-                notificationSoundItem = new NotificationSoundItem(notificationTitle, completeNotificationUri);
-
-                abstractFlexibleItemList.add(notificationSoundItem);
-            }
-        }
-
-        adapter.updateDataSet(abstractFlexibleItemList, false);
-    }
-
-    @Override
-    protected String getTitle() {
-        return getResources().getString(R.string.nc_settings_notification_sounds);
-    }
-
-    @SuppressLint("LongLogTag")
-    @Override
-    public boolean onItemClick(View view, int position) {
-        NotificationSoundItem notificationSoundItem = (NotificationSoundItem) adapter.getItem(position);
-
-        Uri ringtoneUri = null;
-
-        if (!TextUtils.isEmpty(notificationSoundItem.getNotificationSoundUri())) {
-            ringtoneUri = Uri.parse(notificationSoundItem.getNotificationSoundUri());
-
-            endMediaPlayer();
-            mediaPlayer = MediaPlayer.create(getActivity(), ringtoneUri);
-
-            cancelMediaPlayerHandler = new Handler();
-            cancelMediaPlayerHandler.postDelayed(new Runnable() {
-                @Override
-                public void run() {
-                    endMediaPlayer();
-                }
-            }, mediaPlayer.getDuration() + 25);
-            mediaPlayer.start();
-        }
-
-        if (adapter.getSelectedPositions().size() == 0 || adapter.getSelectedPositions().get(0) != position) {
-            RingtoneSettings ringtoneSettings = new RingtoneSettings();
-            ringtoneSettings.setRingtoneName(notificationSoundItem.getNotificationSoundName());
-            ringtoneSettings.setRingtoneUri(ringtoneUri);
-
-            if (callNotificationSounds) {
-                try {
-                    appPreferences.setCallRingtoneUri(LoganSquare.serialize(ringtoneSettings));
-                    adapter.toggleSelection(position);
-                    adapter.notifyDataSetChanged();
-                } catch (IOException e) {
-                    Log.e(TAG, "Failed to store selected ringtone for calls");
-                }
-            } else {
-                try {
-                    appPreferences.setMessageRingtoneUri(LoganSquare.serialize(ringtoneSettings));
-                    adapter.toggleSelection(position);
-                    adapter.notifyDataSetChanged();
-                } catch (IOException e) {
-                    Log.e(TAG, "Failed to store selected ringtone for calls");
-                }
-            }
-        }
-
-        return true;
-    }
-
-    private void endMediaPlayer() {
-        if (cancelMediaPlayerHandler != null) {
-            cancelMediaPlayerHandler.removeCallbacksAndMessages(null);
-        }
-
-        if (mediaPlayer != null) {
-            if (mediaPlayer.isPlaying()) {
-                mediaPlayer.stop();
-            }
-
-            mediaPlayer.release();
-            mediaPlayer = null;
-        }
-    }
-
-    @Override
-    public void onDestroy() {
-        endMediaPlayer();
-        super.onDestroy();
-    }
-
-}

+ 257 - 0
app/src/main/java/com/nextcloud/talk/controllers/RingtoneSelectionController.kt

@@ -0,0 +1,257 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Mario Danic
+ * @author Andy Scherzinger
+ * 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.controllers
+
+import android.annotation.SuppressLint
+import android.media.MediaPlayer
+import android.media.RingtoneManager
+import android.net.Uri
+import android.os.Bundle
+import android.os.Handler
+import android.text.TextUtils
+import android.util.Log
+import android.view.MenuItem
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import autodagger.AutoInjector
+import com.bluelinelabs.logansquare.LoganSquare
+import com.nextcloud.talk.R
+import com.nextcloud.talk.adapters.items.NotificationSoundItem
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.controllers.base.NewBaseController
+import com.nextcloud.talk.controllers.util.viewBinding
+import com.nextcloud.talk.databinding.ControllerGenericRvBinding
+import com.nextcloud.talk.models.RingtoneSettings
+import com.nextcloud.talk.utils.NotificationUtils
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ARE_CALL_SOUNDS
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.davidea.flexibleadapter.SelectableAdapter
+import eu.davidea.flexibleadapter.common.SmoothScrollLinearLayoutManager
+import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
+import java.io.IOException
+import java.util.ArrayList
+
+@AutoInjector(NextcloudTalkApplication::class)
+class RingtoneSelectionController(args: Bundle) :
+    NewBaseController(
+        R.layout.controller_generic_rv,
+        args
+    ),
+    FlexibleAdapter.OnItemClickListener {
+    private val binding: ControllerGenericRvBinding by viewBinding(ControllerGenericRvBinding::bind)
+
+    private var adapter: FlexibleAdapter<*>? = null
+    private var adapterDataObserver: RecyclerView.AdapterDataObserver? = null
+    private val abstractFlexibleItemList: MutableList<AbstractFlexibleItem<*>> = ArrayList()
+    private val callNotificationSounds: Boolean
+    private var mediaPlayer: MediaPlayer? = null
+    private var cancelMediaPlayerHandler: Handler? = null
+
+    override fun onViewBound(view: View) {
+        super.onViewBound(view)
+        if (adapter == null) {
+            adapter = FlexibleAdapter(abstractFlexibleItemList, activity, false)
+            adapter!!.setNotifyChangeOfUnfilteredItems(true).mode = SelectableAdapter.Mode.SINGLE
+            adapter!!.addListener(this)
+            cancelMediaPlayerHandler = Handler()
+        }
+        adapter!!.addListener(this)
+        prepareViews()
+        fetchNotificationSounds()
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        return if (item.itemId == android.R.id.home) {
+            router.popCurrentController()
+        } else {
+            super.onOptionsItemSelected(item)
+        }
+    }
+
+    private fun prepareViews() {
+        val layoutManager: RecyclerView.LayoutManager = SmoothScrollLinearLayoutManager(activity)
+        binding.recyclerView.layoutManager = layoutManager
+        binding.recyclerView.setHasFixedSize(true)
+        binding.recyclerView.adapter = adapter
+        adapterDataObserver = object : RecyclerView.AdapterDataObserver() {
+            override fun onChanged() {
+                super.onChanged()
+                findSelectedSound()
+            }
+        }
+        adapter!!.registerAdapterDataObserver(adapterDataObserver!!)
+        binding.swipeRefreshLayout.isEnabled = false
+    }
+
+    @SuppressLint("LongLogTag")
+    private fun findSelectedSound() {
+        var foundDefault = false
+        var preferencesString: String? = null
+        if (callNotificationSounds &&
+            TextUtils.isEmpty(appPreferences!!.callRingtoneUri.also { preferencesString = it }) ||
+            !callNotificationSounds &&
+            TextUtils.isEmpty(appPreferences!!.messageRingtoneUri.also { preferencesString = it })
+        ) {
+            adapter!!.toggleSelection(1)
+            foundDefault = true
+        }
+        if (!TextUtils.isEmpty(preferencesString) && !foundDefault) {
+            try {
+                val ringtoneSettings: RingtoneSettings =
+                    LoganSquare.parse<RingtoneSettings>(preferencesString, RingtoneSettings::class.java)
+                if (ringtoneSettings.getRingtoneUri() == null) {
+                    adapter!!.toggleSelection(0)
+                } else if (ringtoneSettings.getRingtoneUri().toString() == ringtoneString) {
+                    adapter!!.toggleSelection(1)
+                } else {
+                    var notificationSoundItem: NotificationSoundItem?
+                    for (i in 2 until adapter!!.itemCount) {
+                        notificationSoundItem = adapter!!.getItem(i) as NotificationSoundItem?
+                        if (
+                            notificationSoundItem!!.notificationSoundUri == ringtoneSettings.getRingtoneUri().toString()
+                        ) {
+                            adapter!!.toggleSelection(i)
+                            break
+                        }
+                    }
+                }
+            } catch (e: IOException) {
+                Log.e(TAG, "Failed to parse ringtone settings")
+            }
+        }
+        adapter!!.unregisterAdapterDataObserver(adapterDataObserver!!)
+        adapterDataObserver = null
+    }
+
+    private val ringtoneString: String
+        get() = if (callNotificationSounds) {
+            NotificationUtils.DEFAULT_CALL_RINGTONE_URI
+        } else {
+            NotificationUtils.DEFAULT_MESSAGE_RINGTONE_URI
+        }
+
+    private fun fetchNotificationSounds() {
+        abstractFlexibleItemList.add(
+            NotificationSoundItem(
+                resources!!.getString(R.string.nc_settings_no_ringtone),
+                null
+            )
+        )
+        abstractFlexibleItemList.add(
+            NotificationSoundItem(
+                resources!!.getString(R.string.nc_settings_default_ringtone),
+                ringtoneString
+            )
+        )
+        if (activity != null) {
+            val manager = RingtoneManager(activity)
+            if (callNotificationSounds) {
+                manager.setType(RingtoneManager.TYPE_RINGTONE)
+            } else {
+                manager.setType(RingtoneManager.TYPE_NOTIFICATION)
+            }
+            val cursor = manager.cursor
+            var notificationSoundItem: NotificationSoundItem
+            while (cursor.moveToNext()) {
+                val notificationTitle = cursor.getString(RingtoneManager.TITLE_COLUMN_INDEX)
+                val notificationUri = cursor.getString(RingtoneManager.URI_COLUMN_INDEX)
+                val completeNotificationUri = notificationUri + "/" + cursor.getString(RingtoneManager.ID_COLUMN_INDEX)
+                notificationSoundItem = NotificationSoundItem(notificationTitle, completeNotificationUri)
+                abstractFlexibleItemList.add(notificationSoundItem)
+            }
+        }
+        adapter!!.updateDataSet(abstractFlexibleItemList as List<Nothing>?, false)
+    }
+
+    override fun onItemClick(view: View, position: Int): Boolean {
+        val notificationSoundItem = adapter!!.getItem(position) as NotificationSoundItem?
+        var ringtoneUri: Uri? = null
+        if (!TextUtils.isEmpty(notificationSoundItem!!.notificationSoundUri)) {
+            ringtoneUri = Uri.parse(notificationSoundItem.notificationSoundUri)
+            endMediaPlayer()
+            mediaPlayer = MediaPlayer.create(activity, ringtoneUri)
+            cancelMediaPlayerHandler = Handler()
+            cancelMediaPlayerHandler!!.postDelayed(
+                { endMediaPlayer() },
+                (mediaPlayer!!.duration + DURATION_EXTENSION).toLong()
+            )
+            mediaPlayer!!.start()
+        }
+        if (adapter!!.selectedPositions.size == 0 || adapter!!.selectedPositions[0] != position) {
+            val ringtoneSettings = RingtoneSettings()
+            ringtoneSettings.setRingtoneName(notificationSoundItem.notificationSoundName)
+            ringtoneSettings.setRingtoneUri(ringtoneUri)
+            if (callNotificationSounds) {
+                try {
+                    appPreferences!!.callRingtoneUri = LoganSquare.serialize(ringtoneSettings)
+                    adapter!!.toggleSelection(position)
+                    adapter!!.notifyDataSetChanged()
+                } catch (e: IOException) {
+                    Log.e(TAG, "Failed to store selected ringtone for calls")
+                }
+            } else {
+                try {
+                    appPreferences!!.messageRingtoneUri = LoganSquare.serialize(ringtoneSettings)
+                    adapter!!.toggleSelection(position)
+                    adapter!!.notifyDataSetChanged()
+                } catch (e: IOException) {
+                    Log.e(TAG, "Failed to store selected ringtone for calls")
+                }
+            }
+        }
+        return true
+    }
+
+    private fun endMediaPlayer() {
+        if (cancelMediaPlayerHandler != null) {
+            cancelMediaPlayerHandler!!.removeCallbacksAndMessages(null)
+        }
+        if (mediaPlayer != null) {
+            if (mediaPlayer!!.isPlaying) {
+                mediaPlayer!!.stop()
+            }
+            mediaPlayer!!.release()
+            mediaPlayer = null
+        }
+    }
+
+    public override fun onDestroy() {
+        endMediaPlayer()
+        super.onDestroy()
+    }
+
+    companion object {
+        private const val TAG = "RingtoneSelection"
+        private const val DURATION_EXTENSION = 25
+    }
+
+    init {
+        setHasOptionsMenu(true)
+        sharedApplication!!.componentApplication.inject(this)
+        callNotificationSounds = args.getBoolean(KEY_ARE_CALL_SOUNDS, false)
+    }
+
+    override val title: String
+        get() =
+            resources!!.getString(R.string.nc_settings_notification_sounds)
+}

+ 2 - 1
app/src/main/java/com/nextcloud/talk/controllers/ServerSelectionController.kt

@@ -213,7 +213,7 @@ class ServerSelectionController : NewBaseController(R.layout.controller_server_s
                 val productName = resources!!.getString(R.string.nc_server_product_name)
                 val versionString: String = status.getVersion().substring(0, status.getVersion().indexOf("."))
                 val version: Int = versionString.toInt()
-                if (isServerStatusQueryable(status) && version >= 13) {
+                if (isServerStatusQueryable(status) && version >= MIN_SERVER_MAJOR_VERSION) {
                     router.pushController(
                         RouterTransaction.with(
                             WebViewLoginController(
@@ -369,5 +369,6 @@ class ServerSelectionController : NewBaseController(R.layout.controller_server_s
 
     companion object {
         const val TAG = "ServerSelectionController"
+        const val MIN_SERVER_MAJOR_VERSION = 13
     }
 }

+ 1 - 1
app/src/main/java/com/nextcloud/talk/controllers/SwitchAccountController.kt

@@ -3,7 +3,7 @@
  *
  * @author Mario Danic
  * @author Andy Scherzinger
- * Copyright (C) 2022 Andy Scherzinger (info@andy-scherzinger.de)
+ * Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
  * Copyright (C) 2017 Mario Danic <mario@lovelyhq.com>
  *
  * This program is free software: you can redistribute it and/or modify

+ 0 - 504
app/src/main/java/com/nextcloud/talk/controllers/WebViewLoginController.java

@@ -1,504 +0,0 @@
-/*
- *
- *   Nextcloud Talk application
- *
- *   @author Mario Danic
- *   Copyright (C) 2017 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.controllers;
-
-import android.annotation.SuppressLint;
-import android.content.pm.ActivityInfo;
-import android.graphics.Bitmap;
-import android.net.http.SslCertificate;
-import android.net.http.SslError;
-import android.os.Build;
-import android.os.Bundle;
-import android.security.KeyChain;
-import android.security.KeyChainException;
-import android.text.TextUtils;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.webkit.ClientCertRequest;
-import android.webkit.CookieSyncManager;
-import android.webkit.SslErrorHandler;
-import android.webkit.WebResourceRequest;
-import android.webkit.WebResourceResponse;
-import android.webkit.WebSettings;
-import android.webkit.WebView;
-import android.webkit.WebViewClient;
-import android.widget.ProgressBar;
-
-import com.bluelinelabs.conductor.RouterTransaction;
-import com.bluelinelabs.conductor.changehandler.HorizontalChangeHandler;
-import com.nextcloud.talk.R;
-import com.nextcloud.talk.application.NextcloudTalkApplication;
-import com.nextcloud.talk.controllers.base.BaseController;
-import com.nextcloud.talk.events.CertificateEvent;
-import com.nextcloud.talk.jobs.PushRegistrationWorker;
-import com.nextcloud.talk.models.LoginData;
-import com.nextcloud.talk.models.database.UserEntity;
-import com.nextcloud.talk.utils.DisplayUtils;
-import com.nextcloud.talk.utils.bundle.BundleKeys;
-import com.nextcloud.talk.utils.database.user.UserUtils;
-import com.nextcloud.talk.utils.preferences.AppPreferences;
-import com.nextcloud.talk.utils.singletons.ApplicationWideMessageHolder;
-import com.nextcloud.talk.utils.ssl.MagicTrustManager;
-
-import org.greenrobot.eventbus.EventBus;
-
-import java.lang.reflect.Field;
-import java.net.CookieManager;
-import java.net.URLDecoder;
-import java.security.PrivateKey;
-import java.security.cert.CertificateException;
-import java.security.cert.X509Certificate;
-import java.util.HashMap;
-import java.util.Locale;
-import java.util.Map;
-
-import javax.inject.Inject;
-
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.core.content.res.ResourcesCompat;
-import androidx.work.Data;
-import androidx.work.OneTimeWorkRequest;
-import androidx.work.WorkManager;
-import autodagger.AutoInjector;
-import butterknife.BindView;
-import de.cotech.hw.fido.WebViewFidoBridge;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.disposables.Disposable;
-import io.reactivex.schedulers.Schedulers;
-import io.requery.Persistable;
-import io.requery.reactivex.ReactiveEntityStore;
-
-@AutoInjector(NextcloudTalkApplication.class)
-public class WebViewLoginController extends BaseController {
-
-    public static final String TAG = "WebViewLoginController";
-
-    private final String PROTOCOL_SUFFIX = "://";
-    private final String LOGIN_URL_DATA_KEY_VALUE_SEPARATOR = ":";
-
-    @Inject
-    UserUtils userUtils;
-    @Inject
-    AppPreferences appPreferences;
-    @Inject
-    ReactiveEntityStore<Persistable> dataStore;
-    @Inject
-    MagicTrustManager magicTrustManager;
-    @Inject
-    EventBus eventBus;
-    @Inject
-    CookieManager cookieManager;
-
-
-    @BindView(R.id.webview)
-    WebView webView;
-
-    @BindView(R.id.progress_bar)
-    ProgressBar progressBar;
-
-    private String assembledPrefix;
-
-    private Disposable userQueryDisposable;
-
-    private String baseUrl;
-    private boolean isPasswordUpdate;
-
-    private String username;
-    private String password;
-    private int loginStep = 0;
-
-    private boolean automatedLoginAttempted = false;
-
-    private WebViewFidoBridge webViewFidoBridge;
-
-    public WebViewLoginController(String baseUrl, boolean isPasswordUpdate) {
-        this.baseUrl = baseUrl;
-        this.isPasswordUpdate = isPasswordUpdate;
-    }
-
-    public WebViewLoginController(String baseUrl, boolean isPasswordUpdate, String username, String password) {
-        this.baseUrl = baseUrl;
-        this.isPasswordUpdate = isPasswordUpdate;
-        this.username = username;
-        this.password = password;
-    }
-
-    public WebViewLoginController(Bundle args) {
-        super(args);
-    }
-
-    private String getWebLoginUserAgent() {
-        return Build.MANUFACTURER.substring(0, 1).toUpperCase(Locale.getDefault()) +
-                Build.MANUFACTURER.substring(1).toLowerCase(Locale.getDefault()) + " " + Build.MODEL + " ("
-                + getResources().getString(R.string.nc_app_product_name) + ")";
-    }
-
-    @Override
-    protected View inflateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
-        return inflater.inflate(R.layout.controller_web_view_login, container, false);
-    }
-
-    @SuppressLint("SetJavaScriptEnabled")
-    @Override
-    protected void onViewBound(@NonNull View view) {
-        super.onViewBound(view);
-        NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this);
-
-        if (getActivity() != null) {
-            getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
-        }
-
-        if (getActionBar() != null) {
-            getActionBar().hide();
-        }
-
-        assembledPrefix = getResources().getString(R.string.nc_talk_login_scheme) + PROTOCOL_SUFFIX + "login/";
-
-        webView.getSettings().setAllowFileAccess(false);
-        webView.getSettings().setAllowFileAccessFromFileURLs(false);
-        webView.getSettings().setJavaScriptEnabled(true);
-        webView.getSettings().setJavaScriptCanOpenWindowsAutomatically(false);
-        webView.getSettings().setDomStorageEnabled(true);
-        webView.getSettings().setUserAgentString(getWebLoginUserAgent());
-        webView.getSettings().setSaveFormData(false);
-        webView.getSettings().setSavePassword(false);
-        webView.getSettings().setRenderPriority(WebSettings.RenderPriority.HIGH);
-        webView.clearCache(true);
-        webView.clearFormData();
-        webView.clearHistory();
-        WebView.clearClientCertPreferences(null);
-
-        webViewFidoBridge = WebViewFidoBridge.createInstanceForWebView((AppCompatActivity) getActivity(), webView);
-
-        CookieSyncManager.createInstance(getActivity());
-        android.webkit.CookieManager.getInstance().removeAllCookies(null);
-
-        Map<String, String> headers = new HashMap<>();
-        headers.put("OCS-APIRequest", "true");
-
-        webView.setWebViewClient(new WebViewClient() {
-            private boolean basePageLoaded;
-
-            @Override
-            public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
-                webViewFidoBridge.delegateShouldInterceptRequest(view, request);
-                return super.shouldInterceptRequest(view, request);
-            }
-
-            @Override
-            public void onPageStarted(WebView view, String url, Bitmap favicon) {
-                super.onPageStarted(view, url, favicon);
-                webViewFidoBridge.delegateOnPageStarted(view, url, favicon);
-            }
-
-            @Override
-            public boolean shouldOverrideUrlLoading(WebView view, String url) {
-                if (url.startsWith(assembledPrefix)) {
-                    parseAndLoginFromWebView(url);
-                    return true;
-                }
-                return false;
-            }
-
-            @Override
-            public void onPageFinished(WebView view, String url) {
-                loginStep++;
-
-                if (!basePageLoaded) {
-                    if (progressBar != null) {
-                        progressBar.setVisibility(View.GONE);
-                    }
-
-                    if (webView != null) {
-                        webView.setVisibility(View.VISIBLE);
-                    }
-                    basePageLoaded = true;
-                }
-
-                if (!TextUtils.isEmpty(username) && webView != null) {
-                    if (loginStep == 1) {
-                        webView.loadUrl("javascript: {document.getElementsByClassName('login')[0].click(); };");
-                    } else if (!automatedLoginAttempted) {
-                        automatedLoginAttempted = true;
-                        if (TextUtils.isEmpty(password)) {
-                            webView.loadUrl("javascript:var justStore = document.getElementById('user').value = '" + username + "';");
-                        } else {
-                            webView.loadUrl("javascript: {" +
-                                                    "document.getElementById('user').value = '" + username + "';" +
-                                                    "document.getElementById('password').value = '" + password + "';" +
-                                                    "document.getElementById('submit').click(); };");
-                        }
-                    }
-                }
-
-                super.onPageFinished(view, url);
-            }
-
-            @Override
-            public void onReceivedClientCertRequest(WebView view, ClientCertRequest request) {
-                UserEntity userEntity = userUtils.getCurrentUser();
-
-                String alias = null;
-                if (!isPasswordUpdate) {
-                    alias = appPreferences.getTemporaryClientCertAlias();
-                }
-
-                if (TextUtils.isEmpty(alias) && (userEntity != null)) {
-                    alias = userEntity.getClientCertificate();
-                }
-
-                if (!TextUtils.isEmpty(alias)) {
-                    String finalAlias = alias;
-                    new Thread(() -> {
-                        try {
-                            PrivateKey privateKey = KeyChain.getPrivateKey(getActivity(), finalAlias);
-                            X509Certificate[] certificates = KeyChain.getCertificateChain(getActivity(), finalAlias);
-                            if (privateKey != null && certificates != null) {
-                                request.proceed(privateKey, certificates);
-                            } else {
-                                request.cancel();
-                            }
-                        } catch (KeyChainException | InterruptedException e) {
-                            request.cancel();
-                        }
-                    }).start();
-                } else {
-                    KeyChain.choosePrivateKeyAlias(getActivity(), chosenAlias -> {
-                        if (chosenAlias != null) {
-                            appPreferences.setTemporaryClientCertAlias(chosenAlias);
-                            new Thread(() -> {
-                                PrivateKey privateKey = null;
-                                try {
-                                    privateKey = KeyChain.getPrivateKey(getActivity(), chosenAlias);
-                                    X509Certificate[] certificates = KeyChain.getCertificateChain(getActivity(), chosenAlias);
-                                    if (privateKey != null && certificates != null) {
-                                        request.proceed(privateKey, certificates);
-                                    } else {
-                                        request.cancel();
-                                    }
-                                } catch (KeyChainException | InterruptedException e) {
-                                    request.cancel();
-                                }
-                            }).start();
-                        } else {
-                            request.cancel();
-                        }
-                    }, new String[]{"RSA", "EC"}, null, request.getHost(), request.getPort(), null);
-                }
-            }
-
-            @Override
-            public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
-                try {
-                    SslCertificate sslCertificate = error.getCertificate();
-                    Field f = sslCertificate.getClass().getDeclaredField("mX509Certificate");
-                    f.setAccessible(true);
-                    X509Certificate cert = (X509Certificate) f.get(sslCertificate);
-
-                    if (cert == null) {
-                        handler.cancel();
-                    } else {
-                        try {
-                            magicTrustManager.checkServerTrusted(new X509Certificate[]{cert}, "generic");
-                            handler.proceed();
-                        } catch (CertificateException exception) {
-                            eventBus.post(new CertificateEvent(cert, magicTrustManager, handler));
-                        }
-                    }
-                } catch (Exception exception) {
-                    handler.cancel();
-                }
-            }
-
-            @Override
-            public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
-                super.onReceivedError(view, errorCode, description, failingUrl);
-            }
-        });
-
-        webView.loadUrl(baseUrl + "/index.php/login/flow", headers);
-    }
-
-    private void dispose() {
-        if (userQueryDisposable != null && !userQueryDisposable.isDisposed()) {
-            userQueryDisposable.dispose();
-        }
-
-        userQueryDisposable = null;
-    }
-
-    private void parseAndLoginFromWebView(String dataString) {
-        LoginData loginData = parseLoginData(assembledPrefix, dataString);
-
-        if (loginData != null) {
-            dispose();
-
-            UserEntity currentUser = userUtils.getCurrentUser();
-
-            ApplicationWideMessageHolder.MessageType messageType = null;
-
-            if (!isPasswordUpdate && userUtils.getIfUserWithUsernameAndServer(loginData.getUsername(), baseUrl)) {
-                messageType = ApplicationWideMessageHolder.MessageType.ACCOUNT_UPDATED_NOT_ADDED;
-            }
-
-            if (userUtils.checkIfUserIsScheduledForDeletion(loginData.getUsername(), baseUrl)) {
-                ApplicationWideMessageHolder.getInstance().setMessageType(
-                        ApplicationWideMessageHolder.MessageType.ACCOUNT_SCHEDULED_FOR_DELETION);
-
-                if (!isPasswordUpdate) {
-                    getRouter().popToRoot();
-                } else {
-                    getRouter().popCurrentController();
-                }
-            }
-
-            ApplicationWideMessageHolder.MessageType finalMessageType = messageType;
-            cookieManager.getCookieStore().removeAll();
-
-            if (!isPasswordUpdate && finalMessageType == null) {
-                Bundle bundle = new Bundle();
-                bundle.putString(BundleKeys.INSTANCE.getKEY_USERNAME(), loginData.getUsername());
-                bundle.putString(BundleKeys.INSTANCE.getKEY_TOKEN(), loginData.getToken());
-                bundle.putString(BundleKeys.INSTANCE.getKEY_BASE_URL(), loginData.getServerUrl());
-                String protocol = "";
-
-                if (baseUrl.startsWith("http://")) {
-                    protocol = "http://";
-                } else if (baseUrl.startsWith("https://")) {
-                    protocol = "https://";
-                }
-
-                if (!TextUtils.isEmpty(protocol)) {
-                    bundle.putString(BundleKeys.INSTANCE.getKEY_ORIGINAL_PROTOCOL(), protocol);
-                }
-
-                getRouter().pushController(RouterTransaction.with(new AccountVerificationController
-                        (bundle)).pushChangeHandler(new HorizontalChangeHandler())
-                        .popChangeHandler(new HorizontalChangeHandler()));
-            } else {
-                if (isPasswordUpdate) {
-                    if (currentUser != null) {
-                        userQueryDisposable = userUtils.createOrUpdateUser(null, loginData.getToken(),
-                                null, null, "", Boolean.TRUE,
-                                null, currentUser.getId(), null, appPreferences.getTemporaryClientCertAlias(), null)
-                                .subscribeOn(Schedulers.io())
-                                .observeOn(AndroidSchedulers.mainThread())
-                                .subscribe(userEntity -> {
-                                            if (finalMessageType != null) {
-                                                ApplicationWideMessageHolder.getInstance().setMessageType(finalMessageType);
-                                            }
-
-                                            Data data =
-                                                   new Data.Builder().putString(PushRegistrationWorker.ORIGIN,
-                                                                                "WebViewLoginController#parseAndLoginFromWebView").build();
-                                            OneTimeWorkRequest pushRegistrationWork = new OneTimeWorkRequest.Builder(PushRegistrationWorker.class)
-                                                .setInputData(data)
-                                                .build();
-                                            WorkManager.getInstance().enqueue(pushRegistrationWork);
-
-                                            getRouter().popCurrentController();
-                                        }, throwable -> dispose(),
-                                        this::dispose);
-                    }
-                } else {
-                    if (finalMessageType != null) {
-                        // FIXME when the user registers a new account that was setup before (aka
-                        //  ApplicationWideMessageHolder.MessageType.ACCOUNT_UPDATED_NOT_ADDED)
-                        //  The token is not updated in the database and therefor the account not visible/usable
-                        ApplicationWideMessageHolder.getInstance().setMessageType(finalMessageType);
-                    }
-                    getRouter().popToRoot();
-
-                }
-            }
-        }
-    }
-
-    private LoginData parseLoginData(String prefix, String dataString) {
-        if (dataString.length() < prefix.length()) {
-            return null;
-        }
-
-        LoginData loginData = new LoginData();
-
-        // format is xxx://login/server:xxx&user:xxx&password:xxx
-        String data = dataString.substring(prefix.length());
-
-        String[] values = data.split("&");
-
-        if (values.length != 3) {
-            return null;
-        }
-
-        for (String value : values) {
-            if (value.startsWith("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
-                loginData.setUsername(URLDecoder.decode(
-                        value.substring(("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length())));
-            } else if (value.startsWith("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
-                loginData.setToken(URLDecoder.decode(
-                        value.substring(("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length())));
-            } else if (value.startsWith("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
-                loginData.setServerUrl(URLDecoder.decode(
-                        value.substring(("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length())));
-            } else {
-                return null;
-            }
-        }
-
-        if (!TextUtils.isEmpty(loginData.getServerUrl()) && !TextUtils.isEmpty(loginData.getUsername()) &&
-                !TextUtils.isEmpty(loginData.getToken())) {
-            return loginData;
-        } else {
-            return null;
-        }
-    }
-
-    @Override
-    protected void onAttach(@NonNull View view) {
-        super.onAttach(view);
-
-        if (getActivity() != null && getResources() != null) {
-            DisplayUtils.applyColorToStatusBar(getActivity(), ResourcesCompat.getColor(getResources(), R.color.colorPrimary, null));
-            DisplayUtils.applyColorToNavigationBar(getActivity().getWindow(), ResourcesCompat.getColor(getResources(), R.color.colorPrimary, null));
-        }
-    }
-
-    @Override
-    public void onDestroy() {
-        super.onDestroy();
-        dispose();
-    }
-
-    @Override
-    protected void onDestroyView(@NonNull View view) {
-        super.onDestroyView(view);
-        if (getActivity() != null) {
-            getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR);
-        }
-    }
-
-    @Override
-    public AppBarLayoutType getAppBarLayoutType() {
-        return AppBarLayoutType.EMPTY;
-    }
-}

+ 473 - 0
app/src/main/java/com/nextcloud/talk/controllers/WebViewLoginController.kt

@@ -0,0 +1,473 @@
+/*
+ *   Nextcloud Talk application
+ *
+ *   @author Mario Danic
+ *   @author Andy Scherzinger
+ *   Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
+ *   Copyright (C) 2017 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.controllers
+
+import android.annotation.SuppressLint
+import android.content.pm.ActivityInfo
+import android.graphics.Bitmap
+import android.net.http.SslError
+import android.os.Build
+import android.os.Bundle
+import android.security.KeyChain
+import android.security.KeyChainException
+import android.text.TextUtils
+import android.view.View
+import android.webkit.ClientCertRequest
+import android.webkit.CookieSyncManager
+import android.webkit.SslErrorHandler
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.res.ResourcesCompat
+import androidx.work.Data
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkManager
+import autodagger.AutoInjector
+import com.bluelinelabs.conductor.RouterTransaction
+import com.bluelinelabs.conductor.changehandler.HorizontalChangeHandler
+import com.nextcloud.talk.R
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.controllers.base.NewBaseController
+import com.nextcloud.talk.controllers.util.viewBinding
+import com.nextcloud.talk.databinding.ControllerWebViewLoginBinding
+import com.nextcloud.talk.events.CertificateEvent
+import com.nextcloud.talk.jobs.PushRegistrationWorker
+import com.nextcloud.talk.models.LoginData
+import com.nextcloud.talk.utils.DisplayUtils
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ORIGINAL_PROTOCOL
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_TOKEN
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USERNAME
+import com.nextcloud.talk.utils.database.user.UserUtils
+import com.nextcloud.talk.utils.singletons.ApplicationWideMessageHolder
+import com.nextcloud.talk.utils.ssl.MagicTrustManager
+import de.cotech.hw.fido.WebViewFidoBridge
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.disposables.Disposable
+import io.reactivex.schedulers.Schedulers
+import io.requery.Persistable
+import io.requery.reactivex.ReactiveEntityStore
+import org.greenrobot.eventbus.EventBus
+import java.lang.reflect.Field
+import java.net.CookieManager
+import java.net.URLDecoder
+import java.security.PrivateKey
+import java.security.cert.CertificateException
+import java.security.cert.X509Certificate
+import java.util.HashMap
+import java.util.Locale
+import javax.inject.Inject
+
+@AutoInjector(NextcloudTalkApplication::class)
+class WebViewLoginController(args: Bundle? = null) : NewBaseController(
+    R.layout.controller_web_view_login,
+    args
+) {
+    private val binding: ControllerWebViewLoginBinding by viewBinding(ControllerWebViewLoginBinding::bind)
+
+    @Inject
+    lateinit var userUtils: UserUtils
+
+    @Inject
+    lateinit var dataStore: ReactiveEntityStore<Persistable>
+
+    @Inject
+    lateinit var magicTrustManager: MagicTrustManager
+
+    @Inject
+    lateinit var eventBus: EventBus
+
+    @Inject
+    lateinit var cookieManager: CookieManager
+
+    private var assembledPrefix: String? = null
+    private var userQueryDisposable: Disposable? = null
+    private var baseUrl: String? = null
+    private var isPasswordUpdate = false
+    private var username: String? = null
+    private var password: String? = null
+    private var loginStep = 0
+    private var automatedLoginAttempted = false
+    private var webViewFidoBridge: WebViewFidoBridge? = null
+
+    constructor(baseUrl: String?, isPasswordUpdate: Boolean) : this() {
+        this.baseUrl = baseUrl
+        this.isPasswordUpdate = isPasswordUpdate
+    }
+
+    constructor(baseUrl: String?, isPasswordUpdate: Boolean, username: String?, password: String?) : this() {
+        this.baseUrl = baseUrl
+        this.isPasswordUpdate = isPasswordUpdate
+        this.username = username
+        this.password = password
+    }
+
+    private val webLoginUserAgent: String
+        get() = (
+            Build.MANUFACTURER.substring(0, 1).toUpperCase(Locale.getDefault()) +
+                Build.MANUFACTURER.substring(1).toLowerCase(Locale.getDefault()) +
+                " " +
+                Build.MODEL +
+                " (" +
+                resources!!.getString(R.string.nc_app_product_name) +
+                ")"
+            )
+
+    @SuppressLint("SetJavaScriptEnabled")
+    override fun onViewBound(view: View) {
+        super.onViewBound(view)
+        activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+
+        actionBar?.hide()
+
+        assembledPrefix = resources!!.getString(R.string.nc_talk_login_scheme) + PROTOCOL_SUFFIX + "login/"
+        binding.webview.settings.allowFileAccess = false
+        binding.webview.settings.allowFileAccessFromFileURLs = false
+        binding.webview.settings.javaScriptEnabled = true
+        binding.webview.settings.javaScriptCanOpenWindowsAutomatically = false
+        binding.webview.settings.domStorageEnabled = true
+        binding.webview.settings.setUserAgentString(webLoginUserAgent)
+        binding.webview.settings.saveFormData = false
+        binding.webview.settings.savePassword = false
+        binding.webview.settings.setRenderPriority(WebSettings.RenderPriority.HIGH)
+        binding.webview.clearCache(true)
+        binding.webview.clearFormData()
+        binding.webview.clearHistory()
+        WebView.clearClientCertPreferences(null)
+        webViewFidoBridge = WebViewFidoBridge.createInstanceForWebView(activity as AppCompatActivity?, binding.webview)
+        CookieSyncManager.createInstance(activity)
+        android.webkit.CookieManager.getInstance().removeAllCookies(null)
+        val headers: MutableMap<String, String> = HashMap()
+        headers.put("OCS-APIRequest", "true")
+        binding.webview.webViewClient = object : WebViewClient() {
+            private var basePageLoaded = false
+            override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
+                webViewFidoBridge?.delegateShouldInterceptRequest(view, request)
+                return super.shouldInterceptRequest(view, request)
+            }
+
+            override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
+                super.onPageStarted(view, url, favicon)
+                webViewFidoBridge?.delegateOnPageStarted(view, url, favicon)
+            }
+
+            override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
+                if (url.startsWith(assembledPrefix!!)) {
+                    parseAndLoginFromWebView(url)
+                    return true
+                }
+                return false
+            }
+
+            override fun onPageFinished(view: WebView, url: String) {
+                loginStep++
+                if (!basePageLoaded) {
+                    binding.progressBar.visibility = View.GONE
+                    binding.webview.visibility = View.VISIBLE
+
+                    basePageLoaded = true
+                }
+                if (!TextUtils.isEmpty(username)) {
+                    if (loginStep == 1) {
+                        binding.webview.loadUrl("javascript: {document.getElementsByClassName('login')[0].click(); };")
+                    } else if (!automatedLoginAttempted) {
+                        automatedLoginAttempted = true
+                        if (TextUtils.isEmpty(password)) {
+                            binding.webview.loadUrl(
+                                "javascript:var justStore = document.getElementById('user').value = '$username';"
+                            )
+                        } else {
+                            binding.webview.loadUrl(
+                                "javascript: {" +
+                                    "document.getElementById('user').value = '" + username + "';" +
+                                    "document.getElementById('password').value = '" + password + "';" +
+                                    "document.getElementById('submit').click(); };"
+                            )
+                        }
+                    }
+                }
+                super.onPageFinished(view, url)
+            }
+
+            override fun onReceivedClientCertRequest(view: WebView, request: ClientCertRequest) {
+                val userEntity = userUtils.currentUser
+                var alias: String? = null
+                if (!isPasswordUpdate) {
+                    alias = appPreferences!!.temporaryClientCertAlias
+                }
+                if (TextUtils.isEmpty(alias) && userEntity != null) {
+                    alias = userEntity.clientCertificate
+                }
+                if (!TextUtils.isEmpty(alias)) {
+                    val finalAlias = alias
+                    Thread {
+                        try {
+                            val privateKey = KeyChain.getPrivateKey(activity!!, finalAlias!!)
+                            val certificates = KeyChain.getCertificateChain(
+                                activity!!, finalAlias
+                            )
+                            if (privateKey != null && certificates != null) {
+                                request.proceed(privateKey, certificates)
+                            } else {
+                                request.cancel()
+                            }
+                        } catch (e: KeyChainException) {
+                            request.cancel()
+                        } catch (e: InterruptedException) {
+                            request.cancel()
+                        }
+                    }.start()
+                } else {
+                    KeyChain.choosePrivateKeyAlias(activity!!, { chosenAlias: String? ->
+                        if (chosenAlias != null) {
+                            appPreferences!!.temporaryClientCertAlias = chosenAlias
+                            Thread {
+                                var privateKey: PrivateKey? = null
+                                try {
+                                    privateKey = KeyChain.getPrivateKey(activity!!, chosenAlias)
+                                    val certificates = KeyChain.getCertificateChain(
+                                        activity!!, chosenAlias
+                                    )
+                                    if (privateKey != null && certificates != null) {
+                                        request.proceed(privateKey, certificates)
+                                    } else {
+                                        request.cancel()
+                                    }
+                                } catch (e: KeyChainException) {
+                                    request.cancel()
+                                } catch (e: InterruptedException) {
+                                    request.cancel()
+                                }
+                            }.start()
+                        } else {
+                            request.cancel()
+                        }
+                    }, arrayOf("RSA", "EC"), null, request.host, request.port, null)
+                }
+            }
+
+            override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
+                try {
+                    val sslCertificate = error.certificate
+                    val f: Field = sslCertificate.javaClass.getDeclaredField("mX509Certificate")
+                    f.isAccessible = true
+                    val cert = f[sslCertificate] as X509Certificate
+                    if (cert == null) {
+                        handler.cancel()
+                    } else {
+                        try {
+                            magicTrustManager.checkServerTrusted(arrayOf(cert), "generic")
+                            handler.proceed()
+                        } catch (exception: CertificateException) {
+                            eventBus.post(CertificateEvent(cert, magicTrustManager, handler))
+                        }
+                    }
+                } catch (exception: Exception) {
+                    handler.cancel()
+                }
+            }
+
+            override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) {
+                super.onReceivedError(view, errorCode, description, failingUrl)
+            }
+        }
+        binding.webview.loadUrl("$baseUrl/index.php/login/flow", headers)
+    }
+
+    private fun dispose() {
+        if (userQueryDisposable != null && !userQueryDisposable!!.isDisposed) {
+            userQueryDisposable!!.dispose()
+        }
+        userQueryDisposable = null
+    }
+
+    private fun parseAndLoginFromWebView(dataString: String) {
+        val loginData = parseLoginData(assembledPrefix, dataString)
+        if (loginData != null) {
+            dispose()
+            val currentUser = userUtils.currentUser
+            var messageType: ApplicationWideMessageHolder.MessageType? = null
+            if (!isPasswordUpdate && userUtils.getIfUserWithUsernameAndServer(loginData.username, baseUrl)) {
+                messageType = ApplicationWideMessageHolder.MessageType.ACCOUNT_UPDATED_NOT_ADDED
+            }
+            if (userUtils.checkIfUserIsScheduledForDeletion(loginData.username, baseUrl)) {
+                ApplicationWideMessageHolder.getInstance().setMessageType(
+                    ApplicationWideMessageHolder.MessageType.ACCOUNT_SCHEDULED_FOR_DELETION
+                )
+                if (!isPasswordUpdate) {
+                    router.popToRoot()
+                } else {
+                    router.popCurrentController()
+                }
+            }
+            val finalMessageType = messageType
+            cookieManager.cookieStore.removeAll()
+            if (!isPasswordUpdate && finalMessageType == null) {
+                val bundle = Bundle()
+                bundle.putString(KEY_USERNAME, loginData.username)
+                bundle.putString(KEY_TOKEN, loginData.token)
+                bundle.putString(KEY_BASE_URL, loginData.serverUrl)
+                var protocol = ""
+                if (baseUrl!!.startsWith("http://")) {
+                    protocol = "http://"
+                } else if (baseUrl!!.startsWith("https://")) {
+                    protocol = "https://"
+                }
+                if (!TextUtils.isEmpty(protocol)) {
+                    bundle.putString(KEY_ORIGINAL_PROTOCOL, protocol)
+                }
+                router.pushController(
+                    RouterTransaction.with(AccountVerificationController(bundle))
+                        .pushChangeHandler(HorizontalChangeHandler())
+                        .popChangeHandler(HorizontalChangeHandler())
+                )
+            } else {
+                if (isPasswordUpdate) {
+                    if (currentUser != null) {
+                        userQueryDisposable = userUtils.createOrUpdateUser(
+                            null,
+                            loginData.token,
+                            null,
+                            null,
+                            "",
+                            java.lang.Boolean.TRUE,
+                            null,
+                            currentUser.id,
+                            null,
+                            appPreferences!!.temporaryClientCertAlias,
+                            null
+                        )
+                            .subscribeOn(Schedulers.io())
+                            .observeOn(AndroidSchedulers.mainThread())
+                            .subscribe(
+                                {
+                                    if (finalMessageType != null) {
+                                        ApplicationWideMessageHolder.getInstance().setMessageType(finalMessageType)
+                                    }
+                                    val data = Data.Builder().putString(
+                                        PushRegistrationWorker.ORIGIN,
+                                        "WebViewLoginController#parseAndLoginFromWebView"
+                                    ).build()
+                                    val pushRegistrationWork = OneTimeWorkRequest.Builder(
+                                        PushRegistrationWorker::class.java
+                                    )
+                                        .setInputData(data)
+                                        .build()
+                                    WorkManager.getInstance().enqueue(pushRegistrationWork)
+                                    router.popCurrentController()
+                                },
+                                { dispose() }
+                            ) { dispose() }
+                    }
+                } else {
+                    if (finalMessageType != null) {
+                        // FIXME when the user registers a new account that was setup before (aka
+                        //  ApplicationWideMessageHolder.MessageType.ACCOUNT_UPDATED_NOT_ADDED)
+                        //  The token is not updated in the database and therefor the account not visible/usable
+                        ApplicationWideMessageHolder.getInstance().setMessageType(finalMessageType)
+                    }
+                    router.popToRoot()
+                }
+            }
+        }
+    }
+
+    private fun parseLoginData(prefix: String?, dataString: String): LoginData? {
+        if (dataString.length < prefix!!.length) {
+            return null
+        }
+        val loginData = LoginData()
+
+        // format is xxx://login/server:xxx&user:xxx&password:xxx
+        val data: String = dataString.substring(prefix.length)
+        val values: Array<String> = data.split("&").toTypedArray()
+        if (values.size != PARAMETER_COUNT) {
+            return null
+        }
+        for (value in values) {
+            if (value.startsWith("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
+                loginData.username = URLDecoder.decode(
+                    value.substring(("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
+                )
+            } else if (value.startsWith("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
+                loginData.token = URLDecoder.decode(
+                    value.substring(("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
+                )
+            } else if (value.startsWith("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
+                loginData.serverUrl = URLDecoder.decode(
+                    value.substring(("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
+                )
+            } else {
+                return null
+            }
+        }
+        return if (!TextUtils.isEmpty(loginData.serverUrl) && !TextUtils.isEmpty(loginData.username) &&
+            !TextUtils.isEmpty(loginData.token)
+        ) {
+            loginData
+        } else {
+            null
+        }
+    }
+
+    override fun onAttach(view: View) {
+        super.onAttach(view)
+        if (activity != null && resources != null) {
+            DisplayUtils.applyColorToStatusBar(
+                activity,
+                ResourcesCompat.getColor(resources!!, R.color.colorPrimary, null)
+            )
+            DisplayUtils.applyColorToNavigationBar(
+                activity!!.window,
+                ResourcesCompat.getColor(resources!!, R.color.colorPrimary, null)
+            )
+        }
+    }
+
+    public override fun onDestroy() {
+        super.onDestroy()
+        dispose()
+    }
+
+    override fun onDestroyView(view: View) {
+        super.onDestroyView(view)
+        activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR
+    }
+
+    init {
+        sharedApplication!!.componentApplication.inject(this)
+    }
+
+    override val appBarLayoutType: AppBarLayoutType
+        get() = AppBarLayoutType.EMPTY
+
+    companion object {
+        const val TAG = "WebViewLoginController"
+        private const val PROTOCOL_SUFFIX = "://"
+        private const val LOGIN_URL_DATA_KEY_VALUE_SEPARATOR = ":"
+        private const val PARAMETER_COUNT = 3
+    }
+}

+ 10 - 7
app/src/main/java/com/nextcloud/talk/controllers/bottomsheet/EntryMenuController.kt

@@ -94,7 +94,7 @@ class EntryMenuController(args: Bundle) :
             ApplicationWideMessageHolder.getInstance().setMessageType(null)
             if (binding.okButton.isEnabled) {
                 binding.okButton.isEnabled = false
-                binding.okButton.alpha = 0.7f
+                binding.okButton.alpha = OPACITY_BUTTON_DISABLED
             }
         }
     }
@@ -130,20 +130,20 @@ class EntryMenuController(args: Bundle) :
                         if (conversation!!.getName() == null || !conversation!!.getName().equals(s.toString())) {
                             if (!binding.okButton.isEnabled) {
                                 binding.okButton.isEnabled = true
-                                binding.okButton.alpha = 1.0f
+                                binding.okButton.alpha = OPACITY_ENABLED
                             }
                             binding.textInputLayout.isErrorEnabled = false
                         } else {
                             if (binding.okButton.isEnabled) {
                                 binding.okButton.isEnabled = false
-                                binding.okButton.alpha = 0.38f
+                                binding.okButton.alpha = OPACITY_DISABLED
                             }
                             binding.textInputLayout.error = resources?.getString(R.string.nc_call_name_is_same)
                         }
                     } else if (operation !== ConversationOperationEnum.OPS_CODE_GET_AND_JOIN_ROOM) {
                         if (!binding.okButton.isEnabled) {
                             binding.okButton.isEnabled = true
-                            binding.okButton.alpha = 1.0f
+                            binding.okButton.alpha = OPACITY_ENABLED
                         }
                         binding.textInputLayout.isErrorEnabled = false
                     } else if (
@@ -152,20 +152,20 @@ class EntryMenuController(args: Bundle) :
                     ) {
                         if (!binding.okButton.isEnabled) {
                             binding.okButton.isEnabled = true
-                            binding.okButton.alpha = 1.0f
+                            binding.okButton.alpha = OPACITY_ENABLED
                         }
                         binding.textInputLayout.isErrorEnabled = false
                     } else {
                         if (binding.okButton.isEnabled) {
                             binding.okButton.isEnabled = false
-                            binding.okButton.alpha = 0.38f
+                            binding.okButton.alpha = OPACITY_DISABLED
                         }
                         binding.textInputLayout.error = resources?.getString(R.string.nc_wrong_link)
                     }
                 } else {
                     if (binding.okButton.isEnabled) {
                         binding.okButton.isEnabled = false
-                        binding.okButton.alpha = 0.38f
+                        binding.okButton.alpha = OPACITY_DISABLED
                     }
                     binding.textInputLayout.isErrorEnabled = false
                 }
@@ -334,5 +334,8 @@ class EntryMenuController(args: Bundle) :
                 ConversationOperationEnum.OPS_CODE_SET_PASSWORD,
                 ConversationOperationEnum.OPS_CODE_SHARE_LINK
             )
+        const val OPACITY_DISABLED = 0.38f
+        const val OPACITY_BUTTON_DISABLED = 0.7f
+        const val OPACITY_ENABLED = 1.0f
     }
 }

+ 7 - 1
app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt

@@ -49,6 +49,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA
 import com.nextcloud.talk.models.json.search.ContactsByNumberOverall
 import com.nextcloud.talk.utils.ApiUtils
 import com.nextcloud.talk.utils.ContactUtils
+import com.nextcloud.talk.utils.DateConstants
 import com.nextcloud.talk.utils.database.user.UserUtils
 import com.nextcloud.talk.utils.preferences.AppPreferences
 import io.reactivex.Observer
@@ -97,7 +98,12 @@ class ContactAddressBookWorker(val context: Context, workerParameters: WorkerPar
         // Check if run already at the date
         val force = inputData.getBoolean(KEY_FORCE, false)
         if (!force) {
-            if (System.currentTimeMillis() - appPreferences.getPhoneBookIntegrationLastRun(0L) < 24 * 60 * 60 * 1000) {
+            if (System.currentTimeMillis() - appPreferences.getPhoneBookIntegrationLastRun(0L) <
+                DateConstants.DAYS_DIVIDER *
+                DateConstants.HOURS_DIVIDER *
+                DateConstants.MINUTES_DIVIDER *
+                DateConstants.SECOND_DIVIDER
+            ) {
                 Log.d(TAG, "Already run within last 24h")
                 return Result.success()
             }

+ 9 - 4
app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt

@@ -103,8 +103,8 @@ class DownloadFileToCacheWorker(val context: Context, workerParameters: WorkerPa
         }
 
         var count: Int
-        val data = ByteArray(1024 * 4)
-        val bis: InputStream = BufferedInputStream(body.byteStream(), 1024 * 8)
+        val data = ByteArray(BYTE_UNIT_DIVIDER * DATA_BYTES)
+        val bis: InputStream = BufferedInputStream(body.byteStream(), BYTE_UNIT_DIVIDER * DOWNLOAD_STREAM_SIZE)
         val outputFile = File(context.cacheDir, fileName + "_")
         val output: OutputStream = FileOutputStream(outputFile)
         var total: Long = 0
@@ -116,9 +116,9 @@ class DownloadFileToCacheWorker(val context: Context, workerParameters: WorkerPa
         while (count != -1) {
             if (totalFileSize > -1) {
                 total += count.toLong()
-                val progress = (total * 100 / totalFileSize).toInt()
+                val progress = (total * COMPLETE_PERCENTAGE / totalFileSize).toInt()
                 val currentTime = System.currentTimeMillis() - startTime
-                if (currentTime > 50 * timeCount) {
+                if (currentTime > PROGRESS_THRESHOLD * timeCount) {
                     setProgressAsync(Data.Builder().putInt(PROGRESS, progress).build())
                     timeCount++
                 }
@@ -156,5 +156,10 @@ class DownloadFileToCacheWorker(val context: Context, workerParameters: WorkerPa
         const val KEY_FILE_SIZE = "KEY_FILE_SIZE"
         const val PROGRESS = "PROGRESS"
         const val SUCCESS = "SUCCESS"
+        const val BYTE_UNIT_DIVIDER = 1024
+        const val DATA_BYTES = 4
+        const val DOWNLOAD_STREAM_SIZE = 8
+        const val COMPLETE_PERCENTAGE = 100
+        const val PROGRESS_THRESHOLD = 50
     }
 }

+ 2 - 1
app/src/main/java/com/nextcloud/talk/utils/AccountUtils.kt

@@ -38,6 +38,7 @@ import java.util.Arrays
 object AccountUtils {
 
     private const val TAG = "AccountUtils"
+    private const val MIN_SUPPORTED_FILES_APP_VERSION = 30060151
 
     fun findAccounts(userEntitiesList: List<UserEntity>): List<Account> {
         val context = NextcloudTalkApplication.sharedApplication!!.applicationContext
@@ -110,7 +111,7 @@ object AccountUtils {
         val pm = context.packageManager
         try {
             val packageInfo = pm.getPackageInfo(context.getString(R.string.nc_import_accounts_from), 0)
-            if (packageInfo.versionCode >= 30060151) {
+            if (packageInfo.versionCode >= MIN_SUPPORTED_FILES_APP_VERSION) {
                 val ownSignatures = pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES).signatures
                 val filesAppSignatures = pm.getPackageInfo(
                     context.getString(R.string.nc_import_accounts_from),

+ 30 - 0
app/src/main/java/com/nextcloud/talk/utils/DateConstants.kt

@@ -0,0 +1,30 @@
+/*
+ *   Nextcloud Talk application
+ *
+ *   @author Andy Scherzinger
+ *   Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
+ *
+ *   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.utils
+
+class DateConstants {
+    companion object {
+        const val SECOND_DIVIDER = 1000
+        const val MINUTES_DIVIDER = 60
+        const val HOURS_DIVIDER = 60
+        const val DAYS_DIVIDER = 24
+    }
+}

+ 3 - 7
app/src/main/java/com/nextcloud/talk/utils/DateUtils.kt

@@ -35,10 +35,6 @@ import kotlin.math.roundToInt
 object DateUtils {
 
     private const val TIMESTAMP_CORRECTION_MULTIPLIER = 1000
-    private const val SECOND_DIVIDER = 1000
-    private const val MINUTES_DIVIDER = 60
-    private const val HOURS_DIVIDER = 60
-    private const val DAYS_DIVIDER = 24
 
     fun getLocalDateTimeStringFromTimestamp(timestamp: Long): String {
         val cal = Calendar.getInstance()
@@ -63,9 +59,9 @@ object DateUtils {
         return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
             val fmt = RelativeDateTimeFormatter.getInstance()
             val timeLeftMillis = timestamp * TIMESTAMP_CORRECTION_MULTIPLIER - System.currentTimeMillis()
-            val minutes = timeLeftMillis.toDouble() / SECOND_DIVIDER / MINUTES_DIVIDER
-            val hours = minutes / HOURS_DIVIDER
-            val days = hours / DAYS_DIVIDER
+            val minutes = timeLeftMillis.toDouble() / DateConstants.SECOND_DIVIDER / DateConstants.MINUTES_DIVIDER
+            val hours = minutes / DateConstants.HOURS_DIVIDER
+            val days = hours / DateConstants.DAYS_DIVIDER
 
             val minutesInt = minutes.roundToInt()
             val hoursInt = hours.roundToInt()

+ 1 - 1
app/src/main/java/com/nextcloud/talk/utils/ssl/SSLSocketFactoryCompat.kt

@@ -35,7 +35,7 @@ class SSLSocketFactoryCompat(
         var cipherSuites: Array<String>? = null
 
         init {
-            if (Build.VERSION.SDK_INT >= 23) {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                 // Since Android 6.0 (API level 23),
                 // - TLSv1.1 and TLSv1.2 is enabled by default
                 // - SSLv3 is disabled by default

+ 1 - 1
detekt.yml

@@ -1,5 +1,5 @@
 build:
-  maxIssues: 150
+  maxIssues: 99
   weights:
     # complexity: 2
     # LongParameterList: 1

+ 1 - 1
scripts/analysis/findbugs-results.txt

@@ -1 +1 @@
-446
+438

+ 1 - 1
scripts/analysis/lint-results.txt

@@ -1,2 +1,2 @@
 DO NOT TOUCH; GENERATED BY DRONE
-      <span class="mdl-layout-title">Lint Report: 1 error and 172 warnings</span>
+      <span class="mdl-layout-title">Lint Report: 1 error and 168 warnings</span>