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

View file

@ -81,10 +81,6 @@ import javax.inject.Inject
@SuppressLint("LongLogTag")
@AutoInjector(NextcloudTalkApplication::class)
class MagicFirebaseMessagingService : FirebaseMessagingService() {
companion object {
const val TAG = "MagicFirebaseMessagingService"
}
@JvmField
@Inject
var appPreferences: AppPreferences? = null
@ -162,84 +158,7 @@ class MagicFirebaseMessagingService : FirebaseMessagingService() {
base64DecodedSubject
)
if (signatureVerification!!.signatureValid) {
val cipher = Cipher.getInstance("RSA/None/PKCS1Padding")
cipher.init(Cipher.DECRYPT_MODE, privateKey)
val decryptedSubject = cipher.doFinal(base64DecodedSubject)
decryptedPushMessage = LoganSquare.parse(
String(decryptedSubject),
DecryptedPushMessage::class.java
)
decryptedPushMessage?.apply {
Log.d(TAG, this.toString())
timestamp = System.currentTimeMillis()
if (delete) {
cancelExistingNotificationWithId(
applicationContext,
signatureVerification!!.userEntity,
notificationId
)
} else if (deleteAll) {
cancelAllNotificationsForAccount(applicationContext, signatureVerification!!.userEntity)
} else if (deleteMultiple) {
notificationIds!!.forEach {
cancelExistingNotificationWithId(
applicationContext,
signatureVerification!!.userEntity,
it
)
}
} else if (type == "call") {
val fullScreenIntent = Intent(applicationContext, CallNotificationActivity::class.java)
val bundle = Bundle()
bundle.putString(BundleKeys.KEY_ROOM_ID, decryptedPushMessage!!.id)
bundle.putParcelable(KEY_USER_ENTITY, signatureVerification!!.userEntity)
bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, true)
fullScreenIntent.putExtras(bundle)
fullScreenIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val fullScreenPendingIntent = PendingIntent.getActivity(
this@MagicFirebaseMessagingService,
0,
fullScreenIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
val soundUri = getCallRingtoneUri(applicationContext!!, appPreferences!!)
val notificationChannelId = NotificationUtils.NOTIFICATION_CHANNEL_CALLS_V4
val uri = Uri.parse(signatureVerification!!.userEntity.baseUrl)
val baseUrl = uri.host
val notification =
NotificationCompat.Builder(this@MagicFirebaseMessagingService, notificationChannelId)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setSmallIcon(R.drawable.ic_call_black_24dp)
.setSubText(baseUrl)
.setShowWhen(true)
.setWhen(decryptedPushMessage!!.timestamp)
.setContentTitle(EmojiCompat.get().process(decryptedPushMessage!!.subject))
.setAutoCancel(true)
.setOngoing(true)
// .setTimeoutAfter(45000L)
.setContentIntent(fullScreenPendingIntent)
.setFullScreenIntent(fullScreenPendingIntent, true)
.setSound(soundUri)
.build()
notification.flags = notification.flags or Notification.FLAG_INSISTENT
isServiceInForeground = true
checkIfCallIsActive(signatureVerification!!, decryptedPushMessage!!)
startForeground(decryptedPushMessage!!.timestamp.toInt(), notification)
} else {
val messageData = Data.Builder()
.putString(BundleKeys.KEY_NOTIFICATION_SUBJECT, subject)
.putString(BundleKeys.KEY_NOTIFICATION_SIGNATURE, signature)
.build()
val pushNotificationWork =
OneTimeWorkRequest.Builder(NotificationWorker::class.java).setInputData(messageData)
.build()
WorkManager.getInstance().enqueue(pushNotificationWork)
}
}
decryptMessage(privateKey, base64DecodedSubject, subject, signature)
}
} catch (e1: NoSuchAlgorithmException) {
Log.d(NotificationWorker.TAG, "No proper algorithm to decrypt the message " + e1.localizedMessage)
@ -253,6 +172,92 @@ class MagicFirebaseMessagingService : FirebaseMessagingService() {
}
}
private fun decryptMessage(
privateKey: PrivateKey,
base64DecodedSubject: ByteArray?,
subject: String,
signature: String
) {
val cipher = Cipher.getInstance("RSA/None/PKCS1Padding")
cipher.init(Cipher.DECRYPT_MODE, privateKey)
val decryptedSubject = cipher.doFinal(base64DecodedSubject)
decryptedPushMessage = LoganSquare.parse(
String(decryptedSubject),
DecryptedPushMessage::class.java
)
decryptedPushMessage?.apply {
Log.d(TAG, this.toString())
timestamp = System.currentTimeMillis()
if (delete) {
cancelExistingNotificationWithId(
applicationContext,
signatureVerification!!.userEntity,
notificationId
)
} else if (deleteAll) {
cancelAllNotificationsForAccount(applicationContext, signatureVerification!!.userEntity)
} else if (deleteMultiple) {
notificationIds!!.forEach {
cancelExistingNotificationWithId(
applicationContext,
signatureVerification!!.userEntity,
it
)
}
} else if (type == "call") {
val fullScreenIntent = Intent(applicationContext, CallNotificationActivity::class.java)
val bundle = Bundle()
bundle.putString(BundleKeys.KEY_ROOM_ID, decryptedPushMessage!!.id)
bundle.putParcelable(KEY_USER_ENTITY, signatureVerification!!.userEntity)
bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, true)
fullScreenIntent.putExtras(bundle)
fullScreenIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val fullScreenPendingIntent = PendingIntent.getActivity(
this@MagicFirebaseMessagingService,
0,
fullScreenIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
val soundUri = getCallRingtoneUri(applicationContext!!, appPreferences!!)
val notificationChannelId = NotificationUtils.NOTIFICATION_CHANNEL_CALLS_V4
val uri = Uri.parse(signatureVerification!!.userEntity.baseUrl)
val baseUrl = uri.host
val notification =
NotificationCompat.Builder(this@MagicFirebaseMessagingService, notificationChannelId)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setSmallIcon(R.drawable.ic_call_black_24dp)
.setSubText(baseUrl)
.setShowWhen(true)
.setWhen(decryptedPushMessage!!.timestamp)
.setContentTitle(EmojiCompat.get().process(decryptedPushMessage!!.subject))
.setAutoCancel(true)
.setOngoing(true)
// .setTimeoutAfter(45000L)
.setContentIntent(fullScreenPendingIntent)
.setFullScreenIntent(fullScreenPendingIntent, true)
.setSound(soundUri)
.build()
notification.flags = notification.flags or Notification.FLAG_INSISTENT
isServiceInForeground = true
checkIfCallIsActive(signatureVerification!!, decryptedPushMessage!!)
startForeground(decryptedPushMessage!!.timestamp.toInt(), notification)
} else {
val messageData = Data.Builder()
.putString(BundleKeys.KEY_NOTIFICATION_SUBJECT, subject)
.putString(BundleKeys.KEY_NOTIFICATION_SIGNATURE, signature)
.build()
val pushNotificationWork =
OneTimeWorkRequest.Builder(NotificationWorker::class.java).setInputData(messageData)
.build()
WorkManager.getInstance().enqueue(pushNotificationWork)
}
}
}
private fun checkIfCallIsActive(
signatureVerification: SignatureVerification,
decryptedPushMessage: DecryptedPushMessage
@ -279,8 +284,8 @@ class MagicFirebaseMessagingService : FirebaseMessagingService() {
null
)
.repeatWhen { completed ->
completed.zipWith(Observable.range(1, 12), { _, i -> i })
.flatMap { Observable.timer(5, TimeUnit.SECONDS) }
completed.zipWith(Observable.range(1, OBSERVABLE_COUNT), { _, i -> i })
.flatMap { Observable.timer(OBSERVABLE_DELAY, TimeUnit.SECONDS) }
.takeWhile { isServiceInForeground && hasParticipantsInCall && !inCallOnDifferentDevice }
}
.subscribeOn(Schedulers.io())
@ -318,4 +323,10 @@ class MagicFirebaseMessagingService : FirebaseMessagingService() {
}
})
}
companion object {
const val TAG = "MagicFirebaseMessagingService"
private const val OBSERVABLE_COUNT = 12
private const val OBSERVABLE_DELAY: Long = 5
}
}

View file

@ -2,9 +2,11 @@
* Nextcloud Talk application
*
* @author Mario Danic
* @author Andy Scherzinger
* @author Marcel Hibbe
* Copyright (C) 2017-2019 Mario Danic <mario@lovelyhq.com>
* Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
* Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -97,9 +99,9 @@ class ClosedInterfaceImpl : ClosedInterface, ProviderInstaller.ProviderInstallLi
val periodicTokenRegistration = PeriodicWorkRequest.Builder(
PushRegistrationWorker::class.java,
24,
DAILY,
TimeUnit.HOURS,
10,
FLEX_INTERVAL,
TimeUnit.HOURS
)
.setInputData(data)
@ -115,9 +117,9 @@ class ClosedInterfaceImpl : ClosedInterface, ProviderInstaller.ProviderInstallLi
private fun setUpPeriodicTokenRefreshFromFCM() {
val periodicTokenRefreshFromFCM = PeriodicWorkRequest.Builder(
GetFirebasePushTokenWorker::class.java,
30,
MONTHLY,
TimeUnit.DAYS,
10,
FLEX_INTERVAL,
TimeUnit.DAYS,
)
.build()
@ -128,4 +130,10 @@ class ClosedInterfaceImpl : ClosedInterface, ProviderInstaller.ProviderInstallLi
periodicTokenRefreshFromFCM
)
}
companion object {
const val DAILY: Long = 24
const val MONTHLY: Long = 30
const val FLEX_INTERVAL: Long = 10
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -1 +1 @@
446
438

View file

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