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

Migrate controllers to kotlin
This commit is contained in:
Tim Krueger 2022-03-16 11:22:54 +01:00 committed by GitHub
commit d1d6898ffb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1304 additions and 1210 deletions

View file

@ -12,8 +12,8 @@ import java.util.Locale
class ShareUtilsIT { class ShareUtilsIT {
@Test @Test
fun date() { fun date() {
assertEquals(1207778138000, parseDate2("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(1207778138000, HttpUtils.parseDate("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 { private fun parseDate2(dateStr: String): Date {
@ -39,4 +39,8 @@ class ShareUtilsIT {
"EEE MMM d yyyy HH:mm:ss z" "EEE MMM d yyyy HH:mm:ss z"
) )
} }
companion object {
private const val TEST_DATE_IN_MILLIS = 1207778138000
}
} }

View file

@ -81,10 +81,6 @@ import javax.inject.Inject
@SuppressLint("LongLogTag") @SuppressLint("LongLogTag")
@AutoInjector(NextcloudTalkApplication::class) @AutoInjector(NextcloudTalkApplication::class)
class MagicFirebaseMessagingService : FirebaseMessagingService() { class MagicFirebaseMessagingService : FirebaseMessagingService() {
companion object {
const val TAG = "MagicFirebaseMessagingService"
}
@JvmField @JvmField
@Inject @Inject
var appPreferences: AppPreferences? = null var appPreferences: AppPreferences? = null
@ -162,6 +158,26 @@ class MagicFirebaseMessagingService : FirebaseMessagingService() {
base64DecodedSubject base64DecodedSubject
) )
if (signatureVerification!!.signatureValid) { if (signatureVerification!!.signatureValid) {
decryptMessage(privateKey, base64DecodedSubject, subject, signature)
}
} catch (e1: NoSuchAlgorithmException) {
Log.d(NotificationWorker.TAG, "No proper algorithm to decrypt the message " + e1.localizedMessage)
} catch (e1: NoSuchPaddingException) {
Log.d(NotificationWorker.TAG, "No proper padding to decrypt the message " + e1.localizedMessage)
} catch (e1: InvalidKeyException) {
Log.d(NotificationWorker.TAG, "Invalid private key " + e1.localizedMessage)
}
} catch (exception: Exception) {
Log.d(NotificationWorker.TAG, "Something went very wrong " + exception.localizedMessage)
}
}
private fun decryptMessage(
privateKey: PrivateKey,
base64DecodedSubject: ByteArray?,
subject: String,
signature: String
) {
val cipher = Cipher.getInstance("RSA/None/PKCS1Padding") val cipher = Cipher.getInstance("RSA/None/PKCS1Padding")
cipher.init(Cipher.DECRYPT_MODE, privateKey) cipher.init(Cipher.DECRYPT_MODE, privateKey)
val decryptedSubject = cipher.doFinal(base64DecodedSubject) val decryptedSubject = cipher.doFinal(base64DecodedSubject)
@ -241,17 +257,6 @@ class MagicFirebaseMessagingService : FirebaseMessagingService() {
} }
} }
} }
} catch (e1: NoSuchAlgorithmException) {
Log.d(NotificationWorker.TAG, "No proper algorithm to decrypt the message " + e1.localizedMessage)
} catch (e1: NoSuchPaddingException) {
Log.d(NotificationWorker.TAG, "No proper padding to decrypt the message " + e1.localizedMessage)
} catch (e1: InvalidKeyException) {
Log.d(NotificationWorker.TAG, "Invalid private key " + e1.localizedMessage)
}
} catch (exception: Exception) {
Log.d(NotificationWorker.TAG, "Something went very wrong " + exception.localizedMessage)
}
}
private fun checkIfCallIsActive( private fun checkIfCallIsActive(
signatureVerification: SignatureVerification, signatureVerification: SignatureVerification,
@ -279,8 +284,8 @@ class MagicFirebaseMessagingService : FirebaseMessagingService() {
null null
) )
.repeatWhen { completed -> .repeatWhen { completed ->
completed.zipWith(Observable.range(1, 12), { _, i -> i }) completed.zipWith(Observable.range(1, OBSERVABLE_COUNT), { _, i -> i })
.flatMap { Observable.timer(5, TimeUnit.SECONDS) } .flatMap { Observable.timer(OBSERVABLE_DELAY, TimeUnit.SECONDS) }
.takeWhile { isServiceInForeground && hasParticipantsInCall && !inCallOnDifferentDevice } .takeWhile { isServiceInForeground && hasParticipantsInCall && !inCallOnDifferentDevice }
} }
.subscribeOn(Schedulers.io()) .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
}
} }

View file

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

View file

@ -153,6 +153,18 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : Message
showVoiceMessageLoading() showVoiceMessageLoading()
WorkManager.getInstance(context!!).getWorkInfoByIdLiveData(workInfo.id) WorkManager.getInstance(context!!).getWorkInfoByIdLiveData(workInfo.id)
.observeForever { info: WorkInfo? -> .observeForever { info: WorkInfo? ->
showStatus(info)
}
}
}
} catch (e: ExecutionException) {
Log.e(TAG, "Error when checking if worker already exists", e)
} catch (e: InterruptedException) {
Log.e(TAG, "Error when checking if worker already exists", e)
}
}
private fun showStatus(info: WorkInfo?) {
if (info != null) { if (info != null) {
when (info.state) { when (info.state) {
WorkInfo.State.RUNNING -> { WorkInfo.State.RUNNING -> {
@ -172,14 +184,6 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : Message
} }
} }
} }
}
}
} catch (e: ExecutionException) {
Log.e(TAG, "Error when checking if worker already exists", e)
} catch (e: InterruptedException) {
Log.e(TAG, "Error when checking if worker already exists", e)
}
}
private fun showPlayButton() { private fun showPlayButton() {
binding.playPauseBtn.visibility = View.VISIBLE binding.playPauseBtn.visibility = View.VISIBLE
@ -203,6 +207,18 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : Message
} }
if (!message.isGrouped && !message.isOneToOneConversation) { if (!message.isGrouped && !message.isOneToOneConversation) {
setAvatarOnMessage(message)
} else {
if (message.isOneToOneConversation) {
binding.messageUserAvatar.visibility = View.GONE
} else {
binding.messageUserAvatar.visibility = View.INVISIBLE
}
binding.messageAuthor.visibility = View.GONE
}
}
private fun setAvatarOnMessage(message: ChatMessage) {
binding.messageUserAvatar.visibility = View.VISIBLE binding.messageUserAvatar.visibility = View.VISIBLE
if (message.actorType == "guests") { if (message.actorType == "guests") {
// do nothing, avatar is set // do nothing, avatar is set
@ -228,14 +244,6 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : Message
binding.messageUserAvatar.visibility = View.VISIBLE binding.messageUserAvatar.visibility = View.VISIBLE
binding.messageUserAvatar.setImageDrawable(drawable) binding.messageUserAvatar.setImageDrawable(drawable)
} }
} else {
if (message.isOneToOneConversation) {
binding.messageUserAvatar.visibility = View.GONE
} else {
binding.messageUserAvatar.visibility = View.INVISIBLE
}
binding.messageAuthor.visibility = View.GONE
}
} }
private fun colorizeMessageBubble(message: ChatMessage) { private fun colorizeMessageBubble(message: ChatMessage) {

View file

@ -26,6 +26,7 @@ package com.nextcloud.talk.adapters.messages
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Resources
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable import android.graphics.drawable.LayerDrawable
import android.net.Uri 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.TextMatchers
import com.nextcloud.talk.utils.preferences.AppPreferences import com.nextcloud.talk.utils.preferences.AppPreferences
import com.stfalcon.chatkit.messages.MessageHolders import com.stfalcon.chatkit.messages.MessageHolders
import java.util.HashMap
import javax.inject.Inject import javax.inject.Inject
@AutoInjector(NextcloudTalkApplication::class) @AutoInjector(NextcloudTalkApplication::class)
@ -72,17 +74,117 @@ class MagicIncomingTextMessageViewHolder(itemView: View, payload: Any) : Message
override fun onBind(message: ChatMessage) { override fun onBind(message: ChatMessage) {
super.onBind(message) super.onBind(message)
sharedApplication!!.componentApplication.inject(this) sharedApplication!!.componentApplication.inject(this)
val author: String = message.actorDisplayName processAuthor(message)
if (!TextUtils.isEmpty(author)) {
binding.messageAuthor.text = author if (!message.isGrouped && !message.isOneToOneConversation) {
showAvatarOnChatMessage(message)
} else {
if (message.isOneToOneConversation) {
binding.messageUserAvatar.visibility = View.GONE
} else {
binding.messageUserAvatar.visibility = View.INVISIBLE
}
binding.messageAuthor.visibility = View.GONE
}
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 { binding.messageUserAvatar.setOnClickListener {
(payload as? ProfileBottomSheet)?.showFor(message.actorId, itemView.context) (payload as? ProfileBottomSheet)?.showFor(message.actorId, itemView.context)
} }
} else { } else {
binding.messageAuthor.setText(R.string.nc_nick_guest) binding.messageAuthor.setText(R.string.nc_nick_guest)
} }
}
if (!message.isGrouped && !message.isOneToOneConversation) { 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 {
ResourcesCompat.getColor(resources, R.color.bg_message_list_incoming_bubble, null)
}
var bubbleResource = R.drawable.shape_incoming_message
if (message.isGrouped) {
bubbleResource = R.drawable.shape_grouped_incoming_message
}
val bubbleDrawable = DisplayUtils.getMessageSelector(
bgBubbleColor,
ResourcesCompat.getColor(resources, R.color.transparent, null),
bgBubbleColor, bubbleResource
)
ViewCompat.setBackground(bubble, bubbleDrawable)
}
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
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)
}
}
private fun showAvatarOnChatMessage(message: ChatMessage) {
binding.messageUserAvatar.visibility = View.VISIBLE binding.messageUserAvatar.visibility = View.VISIBLE
if (message.actorType == "guests") { if (message.actorType == "guests") {
// do nothing, avatar is set // do nothing, avatar is set
@ -110,46 +212,14 @@ class MagicIncomingTextMessageViewHolder(itemView: View, payload: Any) : Message
binding.messageUserAvatar.visibility = View.VISIBLE binding.messageUserAvatar.visibility = View.VISIBLE
binding.messageUserAvatar.setImageDrawable(drawable) binding.messageUserAvatar.setImageDrawable(drawable)
} }
} else {
if (message.isOneToOneConversation) {
binding.messageUserAvatar.visibility = View.GONE
} else {
binding.messageUserAvatar.visibility = View.INVISIBLE
}
binding.messageAuthor.visibility = View.GONE
} }
val resources = itemView.resources private fun processMessageParameters(
messageParameters: HashMap<String, HashMap<String, String>>,
val bgBubbleColor = if (message.isDeleted) { message: ChatMessage,
ResourcesCompat.getColor(resources, R.color.bg_message_list_incoming_bubble_deleted, null) messageString: Spannable
} else { ): Spannable {
ResourcesCompat.getColor(resources, R.color.bg_message_list_incoming_bubble, null) var messageStringInternal = messageString
}
var bubbleResource = R.drawable.shape_incoming_message
if (message.isGrouped) {
bubbleResource = R.drawable.shape_grouped_incoming_message
}
val bubbleDrawable = DisplayUtils.getMessageSelector(
bgBubbleColor,
ResourcesCompat.getColor(resources, R.color.transparent, null),
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))
var messageString: Spannable = SpannableString(message.text)
var textSize = context?.resources!!.getDimension(R.dimen.chat_text_size)
if (messageParameters != null && messageParameters.size > 0) {
for (key in messageParameters.keys) { for (key in messageParameters.keys) {
val individualHashMap = message.messageParameters[key] val individualHashMap = message.messageParameters[key]
if (individualHashMap != null) { if (individualHashMap != null) {
@ -159,9 +229,9 @@ class MagicIncomingTextMessageViewHolder(itemView: View, payload: Any) : Message
individualHashMap["type"] == "call" individualHashMap["type"] == "call"
) { ) {
if (individualHashMap["id"] == message.activeUser!!.userId) { if (individualHashMap["id"] == message.activeUser!!.userId) {
messageString = DisplayUtils.searchAndReplaceWithMentionSpan( messageStringInternal = DisplayUtils.searchAndReplaceWithMentionSpan(
binding.messageText.context, binding.messageText.context,
messageString, messageStringInternal,
individualHashMap["id"]!!, individualHashMap["id"]!!,
individualHashMap["name"]!!, individualHashMap["name"]!!,
individualHashMap["type"]!!, individualHashMap["type"]!!,
@ -169,9 +239,9 @@ class MagicIncomingTextMessageViewHolder(itemView: View, payload: Any) : Message
R.xml.chip_you R.xml.chip_you
) )
} else { } else {
messageString = DisplayUtils.searchAndReplaceWithMentionSpan( messageStringInternal = DisplayUtils.searchAndReplaceWithMentionSpan(
binding.messageText.context, binding.messageText.context,
messageString, messageStringInternal,
individualHashMap["id"]!!, individualHashMap["id"]!!,
individualHashMap["name"]!!, individualHashMap["name"]!!,
individualHashMap["type"]!!, individualHashMap["type"]!!,
@ -187,48 +257,10 @@ class MagicIncomingTextMessageViewHolder(itemView: View, payload: Any) : Message
} }
} }
} }
} else if (TextMatchers.isMessageWithSingleEmoticonOnly(message.text)) { return messageStringInternal
textSize = (textSize * 2.5).toFloat()
itemView.isSelected = true
binding.messageAuthor.visibility = View.GONE
} }
binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) companion object {
binding.messageText.text = messageString const val TEXT_SIZE_MULTIPLIER = 2.5
// 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 = 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
}
itemView.setTag(MessageSwipeCallback.REPLYABLE_VIEW_TAG, message.isReplyable)
} }
} }

View file

@ -72,91 +72,25 @@ class MagicOutcomingTextMessageViewHolder(itemView: View) : OutcomingTextMessage
layoutParams.isWrapBefore = false layoutParams.isWrapBefore = false
var textSize = context!!.resources.getDimension(R.dimen.chat_text_size) var textSize = context!!.resources.getDimension(R.dimen.chat_text_size)
if (messageParameters != null && messageParameters.size > 0) { if (messageParameters != null && messageParameters.size > 0) {
for (key in messageParameters.keys) { messageString = processMessageParameters(messageParameters, message, messageString)
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)
}
}
}
}
} else if (TextMatchers.isMessageWithSingleEmoticonOnly(message.text)) { } else if (TextMatchers.isMessageWithSingleEmoticonOnly(message.text)) {
textSize = (textSize * 2.5).toFloat() textSize = (textSize * TEXT_SIZE_MULTIPLIER).toFloat()
layoutParams.isWrapBefore = true layoutParams.isWrapBefore = true
binding.messageTime.setTextColor( binding.messageTime.setTextColor(
ResourcesCompat.getColor(context!!.resources, R.color.warm_grey_four, null) ResourcesCompat.getColor(context!!.resources, R.color.warm_grey_four, null)
) )
realView.isSelected = true realView.isSelected = true
} }
val resources = sharedApplication!!.resources
val bgBubbleColor = if (message.isDeleted) { setBubbleOnChatMessage(message)
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)
}
binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
binding.messageTime.layoutParams = layoutParams binding.messageTime.layoutParams = layoutParams
binding.messageText.text = messageString binding.messageText.text = messageString
// parent message handling // parent message handling
if (!message.isDeleted && message.parentMessage != null) { if (!message.isDeleted && message.parentMessage != null) {
val parentChatMessage = message.parentMessage processParentMessage(message)
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)
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
} else { } else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE binding.messageQuote.quotedChatMessageView.visibility = View.GONE
@ -185,4 +119,92 @@ class MagicOutcomingTextMessageViewHolder(itemView: View) : OutcomingTextMessage
itemView.setTag(MessageSwipeCallback.REPLYABLE_VIEW_TAG, message.isReplyable) 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
}
} }

View file

@ -87,36 +87,11 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : MessageHolders
updateDownloadState(message) updateDownloadState(message)
binding.seekbar.max = message.voiceMessageDuration binding.seekbar.max = message.voiceMessageDuration
if (message.isPlayingVoiceMessage) { handleIsPlayingVoiceMessageState(message)
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
)
}
if (message.isDownloadingVoiceMessage) { handleIsDownloadingVoiceMessageState(message)
showVoiceMessageLoading()
} else {
binding.progressBar.visibility = View.GONE
}
if (message.resetVoiceMessage) { handleResetVoiceMessageState(message)
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
}
binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onStopTrackingTouch(seekBar: SeekBar) { override fun onStopTrackingTouch(seekBar: SeekBar) {
@ -156,6 +131,43 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : MessageHolders
binding.checkMark.setContentDescription(readStatusContentDescriptionString) 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) { private fun updateDownloadState(message: ChatMessage) {
// check if download worker is already running // check if download worker is already running
val fileId = message.getSelectedIndividualHashMap()["id"] val fileId = message.getSelectedIndividualHashMap()["id"]

View file

@ -2,10 +2,12 @@
* *
* Nextcloud Talk application * Nextcloud Talk application
* *
* @author Mario Danic
* @author Marcel Hibbe * @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 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 * 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 * 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 accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build()
val periodicCapabilitiesUpdateWork = PeriodicWorkRequest.Builder( val periodicCapabilitiesUpdateWork = PeriodicWorkRequest.Builder(
CapabilitiesWorker::class.java, CapabilitiesWorker::class.java,
12, TimeUnit.HOURS HALF_DAY, TimeUnit.HOURS
).build() ).build()
val capabilitiesUpdateWork = OneTimeWorkRequest.Builder(CapabilitiesWorker::class.java).build() val capabilitiesUpdateWork = OneTimeWorkRequest.Builder(CapabilitiesWorker::class.java).build()
val signalingSettingsWork = OneTimeWorkRequest.Builder(SignalingSettingsWorker::class.java).build() val signalingSettingsWork = OneTimeWorkRequest.Builder(SignalingSettingsWorker::class.java).build()
@ -218,7 +220,7 @@ class NextcloudTalkApplication : MultiDexApplication(), LifecycleObserver {
private fun buildDefaultImageLoader(): ImageLoader { private fun buildDefaultImageLoader(): ImageLoader {
return ImageLoader.Builder(applicationContext) 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. .crossfade(true) // Show a short crossfade when loading images from network or disk into an ImageView.
.componentRegistry { .componentRegistry {
if (SDK_INT >= P) { if (SDK_INT >= P) {
@ -234,6 +236,8 @@ class NextcloudTalkApplication : MultiDexApplication(), LifecycleObserver {
companion object { companion object {
private val TAG = NextcloudTalkApplication::class.java.simpleName private val TAG = NextcloudTalkApplication::class.java.simpleName
const val FIFTY_PERCENT = 0.5
const val HALF_DAY: Long = 12
//region Singleton //region Singleton
//endregion //endregion

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -49,6 +49,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA
import com.nextcloud.talk.models.json.search.ContactsByNumberOverall import com.nextcloud.talk.models.json.search.ContactsByNumberOverall
import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.ContactUtils 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.database.user.UserUtils
import com.nextcloud.talk.utils.preferences.AppPreferences import com.nextcloud.talk.utils.preferences.AppPreferences
import io.reactivex.Observer import io.reactivex.Observer
@ -97,7 +98,12 @@ class ContactAddressBookWorker(val context: Context, workerParameters: WorkerPar
// Check if run already at the date // Check if run already at the date
val force = inputData.getBoolean(KEY_FORCE, false) val force = inputData.getBoolean(KEY_FORCE, false)
if (!force) { 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") Log.d(TAG, "Already run within last 24h")
return Result.success() return Result.success()
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1 +1 @@
446 438

View file

@ -1,2 +1,2 @@
DO NOT TOUCH; GENERATED BY DRONE 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>