repo created
This commit is contained in:
commit
93184d21d1
1403 changed files with 189511 additions and 0 deletions
12
app/src/main/java/com/nextcloud/talk/PhoneUtils.kt
Normal file
12
app/src/main/java/com/nextcloud/talk/PhoneUtils.kt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Sowjanya Kota <sowjanya.kch@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk
|
||||
|
||||
object PhoneUtils {
|
||||
fun isPhoneNumber(input: String?): Boolean = input?.matches(Regex("^\\+?\\d+$")) == true
|
||||
}
|
||||
|
|
@ -0,0 +1,521 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.account
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.work.Data
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import autodagger.AutoInjector
|
||||
import com.bluelinelabs.logansquare.LoganSquare
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.activities.BaseActivity
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.conversationlist.ConversationsListActivity
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.ActivityAccountVerificationBinding
|
||||
import com.nextcloud.talk.events.EventStatus
|
||||
import com.nextcloud.talk.jobs.AccountRemovalWorker
|
||||
import com.nextcloud.talk.jobs.CapabilitiesWorker
|
||||
import com.nextcloud.talk.jobs.SignalingSettingsWorker
|
||||
import com.nextcloud.talk.jobs.WebsocketConnectionsWorker
|
||||
import com.nextcloud.talk.models.json.capabilities.CapabilitiesOverall
|
||||
import com.nextcloud.talk.models.json.generic.Status
|
||||
import com.nextcloud.talk.models.json.userprofile.UserProfileOverall
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.ClosedInterfaceImpl
|
||||
import com.nextcloud.talk.utils.UriUtils
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_ACCOUNT_IMPORT
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ORIGINAL_PROTOCOL
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_PASSWORD
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_TOKEN
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USERNAME
|
||||
import com.nextcloud.talk.utils.singletons.ApplicationWideMessageHolder
|
||||
import io.reactivex.MaybeObserver
|
||||
import io.reactivex.Observer
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import java.net.CookieManager
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class AccountVerificationActivity : BaseActivity() {
|
||||
|
||||
private lateinit var binding: ActivityAccountVerificationBinding
|
||||
|
||||
@Inject
|
||||
lateinit var ncApi: NcApi
|
||||
|
||||
@Inject
|
||||
lateinit var userManager: UserManager
|
||||
|
||||
@Inject
|
||||
lateinit var cookieManager: CookieManager
|
||||
|
||||
private var internalAccountId: Long = -1
|
||||
private val disposables: MutableList<Disposable> = ArrayList()
|
||||
private var baseUrl: String? = null
|
||||
private var username: String? = null
|
||||
private var token: String? = null
|
||||
private var isAccountImport = false
|
||||
private var originalProtocol: String? = null
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
binding = ActivityAccountVerificationBinding.inflate(layoutInflater)
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
setContentView(binding.root)
|
||||
actionBar?.hide()
|
||||
initSystemBars()
|
||||
|
||||
handleIntent()
|
||||
}
|
||||
|
||||
private fun handleIntent() {
|
||||
val extras = intent.extras!!
|
||||
baseUrl = extras.getString(KEY_BASE_URL)
|
||||
username = extras.getString(KEY_USERNAME)
|
||||
token = extras.getString(KEY_TOKEN)
|
||||
if (extras.containsKey(KEY_IS_ACCOUNT_IMPORT)) {
|
||||
isAccountImport = true
|
||||
}
|
||||
if (extras.containsKey(KEY_ORIGINAL_PROTOCOL)) {
|
||||
originalProtocol = extras.getString(KEY_ORIGINAL_PROTOCOL)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
if (
|
||||
isAccountImport &&
|
||||
!UriUtils.hasHttpProtocolPrefixed(baseUrl!!) ||
|
||||
isNotSameProtocol(baseUrl!!, originalProtocol)
|
||||
) {
|
||||
determineBaseUrlProtocol(true)
|
||||
} else {
|
||||
findServerTalkApp()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isNotSameProtocol(baseUrl: String, originalProtocol: String?): Boolean {
|
||||
if (originalProtocol == null) {
|
||||
return true
|
||||
}
|
||||
return !TextUtils.isEmpty(originalProtocol) && !baseUrl.startsWith(originalProtocol)
|
||||
}
|
||||
|
||||
private fun determineBaseUrlProtocol(checkForcedHttps: Boolean) {
|
||||
cookieManager.cookieStore.removeAll()
|
||||
baseUrl = baseUrl!!.replace("http://", "").replace("https://", "")
|
||||
val queryUrl: String = if (checkForcedHttps) {
|
||||
"https://" + baseUrl + ApiUtils.getUrlPostfixForStatus()
|
||||
} else {
|
||||
"http://" + baseUrl + ApiUtils.getUrlPostfixForStatus()
|
||||
}
|
||||
ncApi.getServerStatus(queryUrl)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(object : Observer<Status?> {
|
||||
override fun onSubscribe(d: Disposable) {
|
||||
disposables.add(d)
|
||||
}
|
||||
|
||||
override fun onNext(status: Status) {
|
||||
baseUrl = if (checkForcedHttps) {
|
||||
"https://$baseUrl"
|
||||
} else {
|
||||
"http://$baseUrl"
|
||||
}
|
||||
if (isAccountImport) {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_BASE_URL, baseUrl)
|
||||
bundle.putString(KEY_USERNAME, username)
|
||||
bundle.putString(KEY_PASSWORD, "")
|
||||
|
||||
val intent = Intent(context, WebViewLoginActivity::class.java)
|
||||
intent.putExtras(bundle)
|
||||
startActivity(intent)
|
||||
} else {
|
||||
findServerTalkApp()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
if (checkForcedHttps) {
|
||||
determineBaseUrlProtocol(false)
|
||||
} else {
|
||||
abortVerification()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onComplete() {
|
||||
// unused atm
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun findServerTalkApp() {
|
||||
val credentials = ApiUtils.getCredentials(username, token)
|
||||
cookieManager.cookieStore.removeAll()
|
||||
|
||||
ncApi.getCapabilities(credentials, ApiUtils.getUrlForCapabilities(baseUrl!!))
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(object : Observer<CapabilitiesOverall> {
|
||||
override fun onSubscribe(d: Disposable) {
|
||||
disposables.add(d)
|
||||
}
|
||||
|
||||
override fun onNext(capabilitiesOverall: CapabilitiesOverall) {
|
||||
val hasTalk =
|
||||
capabilitiesOverall.ocs!!.data!!.capabilities != null &&
|
||||
capabilitiesOverall.ocs!!.data!!.capabilities!!.spreedCapability != null &&
|
||||
capabilitiesOverall.ocs!!.data!!.capabilities!!.spreedCapability!!.features != null &&
|
||||
!capabilitiesOverall.ocs!!.data!!.capabilities!!.spreedCapability!!.features!!.isEmpty()
|
||||
if (hasTalk) {
|
||||
fetchProfile(credentials!!, capabilitiesOverall)
|
||||
} else {
|
||||
if (resources != null) {
|
||||
runOnUiThread {
|
||||
binding.progressText.text = String.format(
|
||||
resources!!.getString(R.string.nc_nextcloud_talk_app_not_installed),
|
||||
resources!!.getString(R.string.nc_app_product_name)
|
||||
)
|
||||
}
|
||||
}
|
||||
ApplicationWideMessageHolder.getInstance().messageType =
|
||||
ApplicationWideMessageHolder.MessageType.SERVER_WITHOUT_TALK
|
||||
abortVerification()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
if (resources != null) {
|
||||
runOnUiThread {
|
||||
binding.progressText.text = String.format(
|
||||
resources!!.getString(R.string.nc_nextcloud_talk_app_not_installed),
|
||||
resources!!.getString(R.string.nc_app_product_name)
|
||||
)
|
||||
}
|
||||
}
|
||||
ApplicationWideMessageHolder.getInstance().messageType =
|
||||
ApplicationWideMessageHolder.MessageType.SERVER_WITHOUT_TALK
|
||||
abortVerification()
|
||||
}
|
||||
|
||||
override fun onComplete() {
|
||||
// unused atm
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun storeProfile(displayName: String?, userId: String, capabilitiesOverall: CapabilitiesOverall) {
|
||||
userManager.storeProfile(
|
||||
username,
|
||||
UserManager.UserAttributes(
|
||||
id = null,
|
||||
serverUrl = baseUrl,
|
||||
currentUser = true,
|
||||
userId = userId,
|
||||
token = token,
|
||||
displayName = displayName,
|
||||
pushConfigurationState = null,
|
||||
capabilities = LoganSquare.serialize(capabilitiesOverall.ocs!!.data!!.capabilities),
|
||||
serverVersion = LoganSquare.serialize(capabilitiesOverall.ocs!!.data!!.serverVersion),
|
||||
certificateAlias = appPreferences.temporaryClientCertAlias,
|
||||
externalSignalingServer = null
|
||||
)
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(object : MaybeObserver<User> {
|
||||
override fun onSubscribe(d: Disposable) {
|
||||
disposables.add(d)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onSuccess(user: User) {
|
||||
internalAccountId = user.id!!
|
||||
if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) {
|
||||
ClosedInterfaceImpl().setUpPushTokenRegistration()
|
||||
} else {
|
||||
Log.w(TAG, "Skipping push registration.")
|
||||
runOnUiThread {
|
||||
binding.progressText.text =
|
||||
""" ${binding.progressText.text}
|
||||
${resources!!.getString(R.string.nc_push_disabled)}
|
||||
""".trimIndent()
|
||||
}
|
||||
fetchAndStoreCapabilities()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onError(e: Throwable) {
|
||||
binding.progressText.text = """ ${binding.progressText.text}""".trimIndent() +
|
||||
resources!!.getString(R.string.nc_display_name_not_stored)
|
||||
abortVerification()
|
||||
}
|
||||
|
||||
override fun onComplete() {
|
||||
// unused atm
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun fetchProfile(credentials: String, capabilitiesOverall: CapabilitiesOverall) {
|
||||
ncApi.getUserProfile(
|
||||
credentials,
|
||||
ApiUtils.getUrlForUserProfile(baseUrl!!)
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(object : Observer<UserProfileOverall> {
|
||||
override fun onSubscribe(d: Disposable) {
|
||||
disposables.add(d)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onNext(userProfileOverall: UserProfileOverall) {
|
||||
var displayName: String? = null
|
||||
if (!TextUtils.isEmpty(userProfileOverall.ocs!!.data!!.displayName)) {
|
||||
displayName = userProfileOverall.ocs!!.data!!.displayName
|
||||
} else if (!TextUtils.isEmpty(userProfileOverall.ocs!!.data!!.displayNameAlt)) {
|
||||
displayName = userProfileOverall.ocs!!.data!!.displayNameAlt
|
||||
}
|
||||
if (!TextUtils.isEmpty(displayName)) {
|
||||
storeProfile(
|
||||
displayName,
|
||||
userProfileOverall.ocs!!.data!!.userId!!,
|
||||
capabilitiesOverall
|
||||
)
|
||||
} else {
|
||||
runOnUiThread {
|
||||
binding.progressText.text =
|
||||
"""
|
||||
${binding.progressText.text}
|
||||
${resources!!.getString(R.string.nc_display_name_not_fetched)}
|
||||
""".trimIndent()
|
||||
}
|
||||
abortVerification()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onError(e: Throwable) {
|
||||
runOnUiThread {
|
||||
binding.progressText.text =
|
||||
"""
|
||||
${binding.progressText.text}
|
||||
${resources!!.getString(R.string.nc_display_name_not_fetched)}
|
||||
""".trimIndent()
|
||||
}
|
||||
abortVerification()
|
||||
}
|
||||
|
||||
override fun onComplete() {
|
||||
// unused atm
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
@Subscribe(threadMode = ThreadMode.BACKGROUND)
|
||||
fun onMessageEvent(eventStatus: EventStatus) {
|
||||
Log.d(TAG, "caught EventStatus of type " + eventStatus.eventType.toString())
|
||||
if (eventStatus.eventType == EventStatus.EventType.PUSH_REGISTRATION) {
|
||||
if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) {
|
||||
runOnUiThread {
|
||||
binding.progressText.text =
|
||||
"""
|
||||
${binding.progressText.text}
|
||||
${resources!!.getString(R.string.nc_push_disabled)}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
fetchAndStoreCapabilities()
|
||||
} else if (eventStatus.eventType == EventStatus.EventType.CAPABILITIES_FETCH) {
|
||||
if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) {
|
||||
runOnUiThread {
|
||||
binding.progressText.text =
|
||||
"""
|
||||
${binding.progressText.text}
|
||||
${resources!!.getString(R.string.nc_capabilities_failed)}
|
||||
""".trimIndent()
|
||||
}
|
||||
abortVerification()
|
||||
} else if (internalAccountId == eventStatus.userId && eventStatus.isAllGood) {
|
||||
fetchAndStoreExternalSignalingSettings()
|
||||
}
|
||||
} else if (eventStatus.eventType == EventStatus.EventType.SIGNALING_SETTINGS) {
|
||||
if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) {
|
||||
runOnUiThread {
|
||||
binding.progressText.text =
|
||||
"""
|
||||
${binding.progressText.text}
|
||||
${resources!!.getString(R.string.nc_external_server_failed)}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
proceedWithLogin()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchAndStoreCapabilities() {
|
||||
val userData =
|
||||
Data.Builder()
|
||||
.putLong(KEY_INTERNAL_USER_ID, internalAccountId)
|
||||
.build()
|
||||
val capabilitiesWork =
|
||||
OneTimeWorkRequest.Builder(CapabilitiesWorker::class.java)
|
||||
.setInputData(userData)
|
||||
.build()
|
||||
WorkManager.getInstance().enqueue(capabilitiesWork)
|
||||
}
|
||||
|
||||
private fun fetchAndStoreExternalSignalingSettings() {
|
||||
val userData =
|
||||
Data.Builder()
|
||||
.putLong(KEY_INTERNAL_USER_ID, internalAccountId)
|
||||
.build()
|
||||
val signalingSettingsWorker = OneTimeWorkRequest.Builder(SignalingSettingsWorker::class.java)
|
||||
.setInputData(userData)
|
||||
.build()
|
||||
val websocketConnectionsWorker = OneTimeWorkRequest.Builder(WebsocketConnectionsWorker::class.java).build()
|
||||
|
||||
WorkManager.getInstance(applicationContext!!)
|
||||
.beginWith(signalingSettingsWorker)
|
||||
.then(websocketConnectionsWorker)
|
||||
.enqueue()
|
||||
}
|
||||
|
||||
private fun proceedWithLogin() {
|
||||
cookieManager.cookieStore.removeAll()
|
||||
|
||||
if (userManager.users.blockingGet().size == 1 ||
|
||||
currentUserProvider.currentUser.blockingGet().id != internalAccountId
|
||||
) {
|
||||
val userToSetAsActive = userManager.getUserWithId(internalAccountId).blockingGet()
|
||||
Log.d(TAG, "userToSetAsActive: " + userToSetAsActive.username)
|
||||
|
||||
if (userManager.setUserAsActive(userToSetAsActive).blockingGet()) {
|
||||
runOnUiThread {
|
||||
if (userManager.users.blockingGet().size == 1) {
|
||||
val intent = Intent(context, ConversationsListActivity::class.java)
|
||||
startActivity(intent)
|
||||
} else {
|
||||
if (isAccountImport) {
|
||||
ApplicationWideMessageHolder.getInstance().messageType =
|
||||
ApplicationWideMessageHolder.MessageType.ACCOUNT_WAS_IMPORTED
|
||||
}
|
||||
val intent = Intent(context, ConversationsListActivity::class.java)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "failed to set active user")
|
||||
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "continuing proceedWithLogin was skipped for this user")
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispose() {
|
||||
for (i in disposables.indices) {
|
||||
if (!disposables[i].isDisposed) {
|
||||
disposables[i].dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override fun onDestroy() {
|
||||
dispose()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun abortVerification() {
|
||||
if (isAccountImport) {
|
||||
ApplicationWideMessageHolder.getInstance().messageType = ApplicationWideMessageHolder.MessageType
|
||||
.FAILED_TO_IMPORT_ACCOUNT
|
||||
runOnUiThread {
|
||||
Handler().postDelayed({
|
||||
val intent = Intent(this, ServerSelectionActivity::class.java)
|
||||
startActivity(intent)
|
||||
}, DELAY_IN_MILLIS)
|
||||
}
|
||||
} else {
|
||||
if (internalAccountId != -1L) {
|
||||
runOnUiThread {
|
||||
deleteUserAndStartServerSelection(internalAccountId)
|
||||
}
|
||||
} else {
|
||||
runOnUiThread {
|
||||
Handler().postDelayed({
|
||||
val intent = Intent(this, ServerSelectionActivity::class.java)
|
||||
startActivity(intent)
|
||||
}, DELAY_IN_MILLIS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
private fun deleteUserAndStartServerSelection(userId: Long) {
|
||||
userManager.scheduleUserForDeletionWithId(userId).blockingGet()
|
||||
val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build()
|
||||
WorkManager.getInstance(applicationContext).enqueue(accountRemovalWork)
|
||||
|
||||
WorkManager.getInstance(context).getWorkInfoByIdLiveData(accountRemovalWork.id)
|
||||
.observeForever { workInfo: WorkInfo? ->
|
||||
|
||||
when (workInfo?.state) {
|
||||
WorkInfo.State.SUCCEEDED -> {
|
||||
val intent = Intent(this, ServerSelectionActivity::class.java)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.resources.getString(R.string.nc_common_error_sorry),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
Log.e(TAG, "something went wrong when deleting user with id $userId")
|
||||
val intent = Intent(this, ServerSelectionActivity::class.java)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = AccountVerificationActivity::class.java.simpleName
|
||||
const val DELAY_IN_MILLIS: Long = 7500
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,468 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.account
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Bundle
|
||||
import android.security.KeyChain
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.bundleOf
|
||||
import autodagger.AutoInjector
|
||||
import com.blikoon.qrcodescanner.QrCodeActivity
|
||||
import com.github.dhaval2404.imagepicker.util.PermissionUtil
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.activities.BaseActivity
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.databinding.ActivityServerSelectionBinding
|
||||
import com.nextcloud.talk.models.json.capabilities.CapabilitiesOverall
|
||||
import com.nextcloud.talk.models.json.generic.Status
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.AccountUtils
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.CapabilitiesUtil
|
||||
import com.nextcloud.talk.utils.UriUtils
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.ADD_ADDITIONAL_ACCOUNT
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_ACCOUNT_IMPORT
|
||||
import com.nextcloud.talk.utils.singletons.ApplicationWideMessageHolder
|
||||
import io.reactivex.Observer
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import java.security.cert.CertificateException
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class ServerSelectionActivity : BaseActivity() {
|
||||
|
||||
private lateinit var binding: ActivityServerSelectionBinding
|
||||
|
||||
@Inject
|
||||
lateinit var ncApi: NcApi
|
||||
|
||||
@Inject
|
||||
lateinit var userManager: UserManager
|
||||
|
||||
private var statusQueryDisposable: Disposable? = null
|
||||
|
||||
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (intent.hasExtra(ADD_ADDITIONAL_ACCOUNT) && intent.getBooleanExtra(ADD_ADDITIONAL_ACCOUNT, false)) {
|
||||
finish()
|
||||
} else {
|
||||
finishAffinity()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
binding = ActivityServerSelectionBinding.inflate(layoutInflater)
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
setContentView(binding.root)
|
||||
actionBar?.hide()
|
||||
initSystemBars()
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
binding.hostUrlInputHelperText.text = String.format(
|
||||
resources!!.getString(R.string.nc_server_helper_text),
|
||||
resources!!.getString(R.string.nc_server_product_name)
|
||||
)
|
||||
binding.serverEntryTextInputLayout.setEndIconOnClickListener { checkServerAndProceed() }
|
||||
|
||||
if (resources!!.getBoolean(R.bool.hide_auth_cert)) {
|
||||
binding.certTextView.visibility = View.GONE
|
||||
}
|
||||
|
||||
val loggedInUsers = userManager.users.blockingGet()
|
||||
val availableAccounts = AccountUtils.findAvailableAccountsOnDevice(loggedInUsers)
|
||||
|
||||
if (isImportAccountNameSet() && availableAccounts.isNotEmpty()) {
|
||||
showImportAccountsInfo(availableAccounts)
|
||||
} else if (isAbleToShowProviderLink() && loggedInUsers.isEmpty()) {
|
||||
showVisitProvidersInfo()
|
||||
} else {
|
||||
binding.importOrChooseProviderText.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
binding.serverEntryTextInputEditText.requestFocus()
|
||||
if (!TextUtils.isEmpty(resources!!.getString(R.string.weblogin_url))) {
|
||||
binding.serverEntryTextInputEditText.setText(resources!!.getString(R.string.weblogin_url))
|
||||
checkServerAndProceed()
|
||||
}
|
||||
binding.serverEntryTextInputEditText.setOnEditorActionListener { _: TextView?, i: Int, _: KeyEvent? ->
|
||||
if (i == EditorInfo.IME_ACTION_DONE) {
|
||||
checkServerAndProceed()
|
||||
}
|
||||
false
|
||||
}
|
||||
binding.certTextView.setOnClickListener { onCertClick() }
|
||||
|
||||
binding.scanQr.setOnClickListener { onScan() }
|
||||
|
||||
if (ApplicationWideMessageHolder.getInstance().messageType != null) {
|
||||
if (ApplicationWideMessageHolder.getInstance().messageType
|
||||
== ApplicationWideMessageHolder.MessageType.SERVER_WITHOUT_TALK
|
||||
) {
|
||||
setErrorText(resources!!.getString(R.string.nc_settings_no_talk_installed))
|
||||
} else if (ApplicationWideMessageHolder.getInstance().messageType
|
||||
== ApplicationWideMessageHolder.MessageType.FAILED_TO_IMPORT_ACCOUNT
|
||||
) {
|
||||
setErrorText(resources!!.getString(R.string.nc_server_failed_to_import_account))
|
||||
}
|
||||
ApplicationWideMessageHolder.getInstance().messageType = null
|
||||
}
|
||||
setCertTextView()
|
||||
}
|
||||
|
||||
fun onCertClick() {
|
||||
KeyChain.choosePrivateKeyAlias(
|
||||
this,
|
||||
{ alias: String? ->
|
||||
if (alias != null) {
|
||||
appPreferences.temporaryClientCertAlias = alias
|
||||
} else {
|
||||
appPreferences.removeTemporaryClientCertAlias()
|
||||
}
|
||||
setCertTextView()
|
||||
},
|
||||
arrayOf("RSA", "EC"),
|
||||
null,
|
||||
null,
|
||||
-1,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
private fun isAbleToShowProviderLink(): Boolean =
|
||||
!resources!!.getBoolean(R.bool.hide_provider) &&
|
||||
!TextUtils.isEmpty(resources!!.getString(R.string.nc_providers_url))
|
||||
|
||||
private fun showImportAccountsInfo(availableAccounts: List<Account>) {
|
||||
if (!TextUtils.isEmpty(
|
||||
AccountUtils.getAppNameBasedOnPackage(resources!!.getString(R.string.nc_import_accounts_from))
|
||||
)
|
||||
) {
|
||||
if (availableAccounts.size > 1) {
|
||||
binding.importOrChooseProviderText.text = String.format(
|
||||
resources!!.getString(R.string.nc_server_import_accounts),
|
||||
AccountUtils.getAppNameBasedOnPackage(resources!!.getString(R.string.nc_import_accounts_from))
|
||||
)
|
||||
} else {
|
||||
binding.importOrChooseProviderText.text = String.format(
|
||||
resources!!.getString(R.string.nc_server_import_account),
|
||||
AccountUtils.getAppNameBasedOnPackage(resources!!.getString(R.string.nc_import_accounts_from))
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (availableAccounts.size > 1) {
|
||||
binding.importOrChooseProviderText.text =
|
||||
resources!!.getString(R.string.nc_server_import_accounts_plain)
|
||||
} else {
|
||||
binding.importOrChooseProviderText.text =
|
||||
resources!!.getString(R.string.nc_server_import_account_plain)
|
||||
}
|
||||
}
|
||||
binding.importOrChooseProviderText.setOnClickListener {
|
||||
val bundle = Bundle()
|
||||
bundle.putBoolean(KEY_IS_ACCOUNT_IMPORT, true)
|
||||
val intent = Intent(context, SwitchAccountActivity::class.java)
|
||||
intent.putExtras(bundle)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showVisitProvidersInfo() {
|
||||
binding.importOrChooseProviderText.setText(R.string.nc_get_from_provider)
|
||||
binding.importOrChooseProviderText.setOnClickListener {
|
||||
val browserIntent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
resources!!.getString(R.string.nc_providers_url).toUri()
|
||||
)
|
||||
startActivity(browserIntent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isImportAccountNameSet(): Boolean =
|
||||
!TextUtils.isEmpty(resources!!.getString(R.string.nc_import_account_type))
|
||||
|
||||
@SuppressLint("LongLogTag")
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
private fun checkServerAndProceed() {
|
||||
dispose()
|
||||
var url: String = binding.serverEntryTextInputEditText.text.toString().trim()
|
||||
showserverEntryProgressBar()
|
||||
if (binding.importOrChooseProviderText.visibility != View.INVISIBLE) {
|
||||
binding.importOrChooseProviderText.visibility = View.INVISIBLE
|
||||
binding.certTextView.visibility = View.INVISIBLE
|
||||
}
|
||||
if (url.endsWith("/")) {
|
||||
url = url.substring(0, url.length - 1)
|
||||
}
|
||||
|
||||
if (UriUtils.hasHttpProtocolPrefixed(url)) {
|
||||
checkServer(url, false)
|
||||
} else {
|
||||
checkServer("https://$url", true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkServer(url: String, checkForcedHttps: Boolean) {
|
||||
val queryStatusUrl = url + ApiUtils.getUrlPostfixForStatus()
|
||||
|
||||
statusQueryDisposable = ncApi.getServerStatus(queryStatusUrl)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ status: Status ->
|
||||
val versionString: String = status.version!!.substring(0, status.version!!.indexOf("."))
|
||||
val version: Int = versionString.toInt()
|
||||
|
||||
if (isServerStatusQueryable(status) && version >= MIN_SERVER_MAJOR_VERSION) {
|
||||
findServerTalkApp(url)
|
||||
} else {
|
||||
showErrorTextForStatus(status)
|
||||
}
|
||||
}, { throwable: Throwable ->
|
||||
if (checkForcedHttps) {
|
||||
checkServer(queryStatusUrl.replace("https://", "http://"), false)
|
||||
} else {
|
||||
if (throwable.localizedMessage != null) {
|
||||
setErrorText(throwable.localizedMessage)
|
||||
} else if (throwable.cause is CertificateException) {
|
||||
setErrorText(resources!!.getString(R.string.nc_certificate_error))
|
||||
} else {
|
||||
hideserverEntryProgressBar()
|
||||
}
|
||||
|
||||
if (binding.importOrChooseProviderText.visibility != View.INVISIBLE) {
|
||||
binding.importOrChooseProviderText.visibility = View.VISIBLE
|
||||
binding.certTextView.visibility = View.VISIBLE
|
||||
}
|
||||
dispose()
|
||||
}
|
||||
}) {
|
||||
hideserverEntryProgressBar()
|
||||
if (binding.importOrChooseProviderText.visibility != View.INVISIBLE) {
|
||||
binding.importOrChooseProviderText.visibility = View.VISIBLE
|
||||
binding.certTextView.visibility = View.VISIBLE
|
||||
}
|
||||
dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showErrorTextForStatus(status: Status) {
|
||||
if (!status.installed) {
|
||||
setErrorText(
|
||||
String.format(
|
||||
resources!!.getString(R.string.nc_server_not_installed),
|
||||
resources!!.getString(R.string.nc_server_product_name)
|
||||
)
|
||||
)
|
||||
} else if (status.needsUpgrade) {
|
||||
setErrorText(
|
||||
String.format(
|
||||
resources!!.getString(R.string.nc_server_db_upgrade_needed),
|
||||
resources!!.getString(R.string.nc_server_product_name)
|
||||
)
|
||||
)
|
||||
} else if (status.maintenance) {
|
||||
setErrorText(
|
||||
String.format(
|
||||
resources!!.getString(R.string.nc_server_maintenance),
|
||||
resources!!.getString(R.string.nc_server_product_name)
|
||||
)
|
||||
)
|
||||
} else if (!status.version!!.startsWith("13.")) {
|
||||
setErrorText(
|
||||
String.format(
|
||||
resources!!.getString(R.string.nc_server_version),
|
||||
resources!!.getString(R.string.nc_app_product_name),
|
||||
resources!!.getString(R.string.nc_server_product_name)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findServerTalkApp(queryUrl: String) {
|
||||
ncApi.getCapabilities(ApiUtils.getUrlForCapabilities(queryUrl))
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(object : Observer<CapabilitiesOverall> {
|
||||
override fun onSubscribe(d: Disposable) {
|
||||
// unused atm
|
||||
}
|
||||
|
||||
override fun onNext(capabilitiesOverall: CapabilitiesOverall) {
|
||||
val capabilities = capabilitiesOverall.ocs?.data?.capabilities
|
||||
|
||||
val hasTalk =
|
||||
capabilities?.spreedCapability != null &&
|
||||
capabilities.spreedCapability?.features != null &&
|
||||
capabilities.spreedCapability?.features?.isNotEmpty() == true
|
||||
|
||||
if (hasTalk) {
|
||||
runOnUiThread {
|
||||
if (CapabilitiesUtil.isServerEOL(capabilitiesOverall.ocs?.data?.serverVersion?.major)) {
|
||||
if (resources != null) {
|
||||
runOnUiThread {
|
||||
setErrorText(resources!!.getString(R.string.nc_settings_server_eol))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(BundleKeys.KEY_BASE_URL, queryUrl.replace("/status.php", ""))
|
||||
|
||||
val intent = Intent(context, WebViewLoginActivity::class.java)
|
||||
intent.putExtras(bundle)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (resources != null) {
|
||||
runOnUiThread {
|
||||
setErrorText(resources!!.getString(R.string.nc_server_unsupported))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
Log.e(TAG, "Error while checking capabilities", e)
|
||||
if (resources != null) {
|
||||
runOnUiThread {
|
||||
setErrorText(resources!!.getString(R.string.nc_common_error_sorry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onComplete() {
|
||||
// unused atm
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun isServerStatusQueryable(status: Status): Boolean =
|
||||
status.installed && !status.maintenance && !status.needsUpgrade
|
||||
|
||||
private fun setErrorText(text: String?) {
|
||||
binding.errorWrapper.visibility = View.VISIBLE
|
||||
binding.errorText.text = text
|
||||
hideserverEntryProgressBar()
|
||||
}
|
||||
|
||||
private fun showserverEntryProgressBar() {
|
||||
binding.errorWrapper.visibility = View.INVISIBLE
|
||||
binding.serverEntryProgressBar.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun hideserverEntryProgressBar() {
|
||||
binding.serverEntryProgressBar.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
@SuppressLint("LongLogTag")
|
||||
private fun setCertTextView() {
|
||||
runOnUiThread {
|
||||
if (!TextUtils.isEmpty(appPreferences.temporaryClientCertAlias)) {
|
||||
binding.certTextView.setText(R.string.nc_change_cert_auth)
|
||||
} else {
|
||||
binding.certTextView.setText(R.string.nc_configure_cert_auth)
|
||||
}
|
||||
hideserverEntryProgressBar()
|
||||
}
|
||||
}
|
||||
|
||||
private val requestCameraPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
// Permission was granted
|
||||
startQRScanner()
|
||||
}
|
||||
}
|
||||
|
||||
fun onScan() {
|
||||
if (PermissionUtil.isPermissionGranted(this, Manifest.permission.CAMERA)) {
|
||||
startQRScanner()
|
||||
} else {
|
||||
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startQRScanner() {
|
||||
val intent = Intent(this, QrCodeActivity::class.java)
|
||||
qrScanResultLauncher.launch(intent)
|
||||
}
|
||||
|
||||
private val qrScanResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
val data = result.data
|
||||
|
||||
if (data == null) {
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val resultData = data.getStringExtra(QR_URI)
|
||||
|
||||
if (resultData == null || !resultData.startsWith("nc")) {
|
||||
Snackbar.make(binding.root, getString(R.string.qr_code_error), Snackbar.LENGTH_SHORT).show()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val intent = Intent(this, WebViewLoginActivity::class.java)
|
||||
val bundle = bundleOf().apply {
|
||||
putString(BundleKeys.KEY_FROM_QR, resultData)
|
||||
}
|
||||
intent.putExtras(bundle)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
public override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
dispose()
|
||||
}
|
||||
|
||||
private fun dispose() {
|
||||
if (statusQueryDisposable != null && !statusQueryDisposable!!.isDisposed) {
|
||||
statusQueryDisposable!!.dispose()
|
||||
}
|
||||
statusQueryDisposable = null
|
||||
}
|
||||
|
||||
override val appBarLayoutType: AppBarLayoutType
|
||||
get() = AppBarLayoutType.EMPTY
|
||||
|
||||
companion object {
|
||||
private val TAG = ServerSelectionActivity::class.java.simpleName
|
||||
const val MIN_SERVER_MAJOR_VERSION = 13
|
||||
private const val QR_URI = "com.blikoon.qrcodescanner.got_qr_scan_relult"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Bundle
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import autodagger.AutoInjector
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.activities.BaseActivity
|
||||
import com.nextcloud.talk.adapters.items.AdvancedUserItem
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.ActivitySwitchAccountBinding
|
||||
import com.nextcloud.talk.models.ImportAccount
|
||||
import com.nextcloud.talk.models.json.participants.Participant
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.AccountUtils.findAvailableAccountsOnDevice
|
||||
import com.nextcloud.talk.utils.AccountUtils.getInformationFromAccount
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_ACCOUNT_IMPORT
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_TOKEN
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USERNAME
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.common.SmoothScrollLinearLayoutManager
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import org.osmdroid.config.Configuration
|
||||
import java.net.CookieManager
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Parts related to account import were either copied from or inspired by the great work done by David Luhmer at:
|
||||
* https://github.com/nextcloud/ownCloud-Account-Importer
|
||||
*/
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class SwitchAccountActivity : BaseActivity() {
|
||||
private lateinit var binding: ActivitySwitchAccountBinding
|
||||
|
||||
@Inject
|
||||
lateinit var userManager: UserManager
|
||||
|
||||
@Inject
|
||||
lateinit var cookieManager: CookieManager
|
||||
|
||||
private var adapter: FlexibleAdapter<AbstractFlexibleItem<*>>? = null
|
||||
private val userItems: MutableList<AbstractFlexibleItem<*>> = ArrayList()
|
||||
private var isAccountImport = false
|
||||
|
||||
private val onImportItemClickListener = FlexibleAdapter.OnItemClickListener { _, position ->
|
||||
if (userItems.size > position) {
|
||||
val account = (userItems[position] as AdvancedUserItem).account
|
||||
reauthorizeFromImport(account)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
private val onSwitchItemClickListener = FlexibleAdapter.OnItemClickListener { _, position ->
|
||||
if (userItems.size > position) {
|
||||
val user = (userItems[position] as AdvancedUserItem).user
|
||||
|
||||
if (userManager.setUserAsActive(user!!).blockingGet()) {
|
||||
cookieManager.cookieStore.removeAll()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
binding = ActivitySwitchAccountBinding.inflate(layoutInflater)
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
setContentView(binding.root)
|
||||
setupActionBar()
|
||||
initSystemBars()
|
||||
|
||||
Configuration.getInstance().load(context, PreferenceManager.getDefaultSharedPreferences(context))
|
||||
|
||||
handleIntent()
|
||||
}
|
||||
|
||||
private fun handleIntent() {
|
||||
intent.extras?.let {
|
||||
if (it.containsKey(KEY_IS_ACCOUNT_IMPORT)) {
|
||||
isAccountImport = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupActionBar() {
|
||||
setSupportActionBar(binding.toolbar)
|
||||
binding.toolbar.setNavigationOnClickListener {
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setDisplayShowHomeEnabled(true)
|
||||
supportActionBar?.setIcon(resources!!.getColor(R.color.transparent, null).toDrawable())
|
||||
supportActionBar?.title = resources!!.getString(R.string.nc_select_an_account)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.NestedBlockDepth")
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
if (adapter == null) {
|
||||
adapter = FlexibleAdapter(userItems, this, false)
|
||||
var participant: Participant
|
||||
|
||||
if (!isAccountImport) {
|
||||
for (user in userManager.users.blockingGet()) {
|
||||
if (!user.current) {
|
||||
val userId: String? = if (user.userId != null) {
|
||||
user.userId
|
||||
} else {
|
||||
user.username
|
||||
}
|
||||
participant = Participant()
|
||||
participant.actorType = Participant.ActorType.USERS
|
||||
participant.actorId = userId
|
||||
participant.displayName = user.displayName
|
||||
userItems.add(AdvancedUserItem(participant, user, null, viewThemeUtils, 0))
|
||||
}
|
||||
}
|
||||
adapter!!.addListener(onSwitchItemClickListener)
|
||||
adapter!!.updateDataSet(userItems, false)
|
||||
} else {
|
||||
var account: Account
|
||||
var importAccount: ImportAccount
|
||||
var user: User
|
||||
for (accountObject in findAvailableAccountsOnDevice(userManager.users.blockingGet())) {
|
||||
account = accountObject
|
||||
importAccount = getInformationFromAccount(account)
|
||||
participant = Participant()
|
||||
participant.actorType = Participant.ActorType.USERS
|
||||
participant.actorId = importAccount.getUsername()
|
||||
participant.displayName = importAccount.getUsername()
|
||||
user = User()
|
||||
user.baseUrl = importAccount.getBaseUrl()
|
||||
userItems.add(AdvancedUserItem(participant, user, account, viewThemeUtils, 0))
|
||||
}
|
||||
adapter!!.addListener(onImportItemClickListener)
|
||||
adapter!!.updateDataSet(userItems, false)
|
||||
}
|
||||
}
|
||||
prepareViews()
|
||||
}
|
||||
|
||||
private fun prepareViews() {
|
||||
val layoutManager: LinearLayoutManager = SmoothScrollLinearLayoutManager(this)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.adapter = adapter
|
||||
}
|
||||
|
||||
private fun reauthorizeFromImport(account: Account?) {
|
||||
val importAccount = getInformationFromAccount(account!!)
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_BASE_URL, importAccount.getBaseUrl())
|
||||
bundle.putString(KEY_USERNAME, importAccount.getUsername())
|
||||
bundle.putString(KEY_TOKEN, importAccount.getToken())
|
||||
bundle.putBoolean(KEY_IS_ACCOUNT_IMPORT, true)
|
||||
|
||||
val intent = Intent(context, AccountVerificationActivity::class.java)
|
||||
intent.putExtras(bundle)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,471 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Your Name <your@email.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.account
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
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.util.Log
|
||||
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.activity.OnBackPressedCallback
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import autodagger.AutoInjector
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.activities.BaseActivity
|
||||
import com.nextcloud.talk.activities.MainActivity
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.databinding.ActivityWebViewLoginBinding
|
||||
import com.nextcloud.talk.events.CertificateEvent
|
||||
import com.nextcloud.talk.jobs.AccountRemovalWorker
|
||||
import com.nextcloud.talk.models.LoginData
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys
|
||||
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.ssl.TrustManager
|
||||
import de.cotech.hw.fido.WebViewFidoBridge
|
||||
import de.cotech.hw.fido2.WebViewWebauthnBridge
|
||||
import de.cotech.hw.fido2.ui.WebauthnDialogOptions
|
||||
import io.reactivex.disposables.Disposable
|
||||
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.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("ReturnCount", "LongMethod")
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class WebViewLoginActivity : BaseActivity() {
|
||||
|
||||
private lateinit var binding: ActivityWebViewLoginBinding
|
||||
|
||||
@Inject
|
||||
lateinit var userManager: UserManager
|
||||
|
||||
@Inject
|
||||
lateinit var trustManager: TrustManager
|
||||
|
||||
@Inject
|
||||
lateinit var cookieManager: CookieManager
|
||||
|
||||
private var assembledPrefix: String? = null
|
||||
private var userQueryDisposable: Disposable? = null
|
||||
private var baseUrl: String? = null
|
||||
private var reauthorizeAccount = false
|
||||
private var username: String? = null
|
||||
private var password: String? = null
|
||||
private var loginStep = 0
|
||||
private var automatedLoginAttempted = false
|
||||
private var webViewFidoBridge: WebViewFidoBridge? = null
|
||||
private var webViewWebauthnBridge: WebViewWebauthnBridge? = null
|
||||
|
||||
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
private val webLoginUserAgent: String
|
||||
get() = (
|
||||
Build.MANUFACTURER.substring(0, 1).uppercase(Locale.getDefault()) +
|
||||
Build.MANUFACTURER.substring(1).uppercase(Locale.getDefault()) +
|
||||
" " +
|
||||
Build.MODEL +
|
||||
" (" +
|
||||
resources!!.getString(R.string.nc_app_product_name) +
|
||||
")"
|
||||
)
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
binding = ActivityWebViewLoginBinding.inflate(layoutInflater)
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
setContentView(binding.root)
|
||||
actionBar?.hide()
|
||||
initSystemBars()
|
||||
assembledPrefix = resources!!.getString(R.string.nc_talk_login_scheme) + PROTOCOL_SUFFIX + "login/"
|
||||
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
|
||||
handleIntent()
|
||||
}
|
||||
|
||||
private fun handleIntent() {
|
||||
val extras = intent.extras!!
|
||||
baseUrl = extras.getString(KEY_BASE_URL)
|
||||
username = extras.getString(KEY_USERNAME)
|
||||
|
||||
if (extras.containsKey(BundleKeys.KEY_REAUTHORIZE_ACCOUNT)) {
|
||||
reauthorizeAccount = extras.getBoolean(BundleKeys.KEY_REAUTHORIZE_ACCOUNT)
|
||||
}
|
||||
|
||||
if (extras.containsKey(BundleKeys.KEY_PASSWORD)) {
|
||||
password = extras.getString(BundleKeys.KEY_PASSWORD)
|
||||
}
|
||||
|
||||
if (extras.containsKey(BundleKeys.KEY_FROM_QR)) {
|
||||
extras.getString(BundleKeys.KEY_FROM_QR)?.let {
|
||||
parseAndLoginFromWebView(it)
|
||||
}
|
||||
} else {
|
||||
setupWebView()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun setupWebView() {
|
||||
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.userAgentString = 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(this, binding.webview)
|
||||
|
||||
val webauthnOptionsBuilder = WebauthnDialogOptions.builder().setShowSdkLogo(true).setAllowSkipPin(true)
|
||||
webViewWebauthnBridge = WebViewWebauthnBridge.createInstanceForWebView(
|
||||
this,
|
||||
binding.webview,
|
||||
webauthnOptionsBuilder
|
||||
)
|
||||
|
||||
CookieSyncManager.createInstance(this)
|
||||
android.webkit.CookieManager.getInstance().removeAllCookies(null)
|
||||
val headers: MutableMap<String, String> = HashMap()
|
||||
headers["OCS-APIRequest"] = "true"
|
||||
binding.webview.webViewClient = object : WebViewClient() {
|
||||
private var basePageLoaded = false
|
||||
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||
webViewFidoBridge?.delegateShouldInterceptRequest(view, request)
|
||||
webViewWebauthnBridge?.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)
|
||||
webViewWebauthnBridge?.delegateOnPageStarted(view, url, favicon)
|
||||
}
|
||||
|
||||
@Deprecated("Use shouldOverrideUrlLoading(WebView view, WebResourceRequest request)")
|
||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
if (url.startsWith(assembledPrefix!!)) {
|
||||
parseAndLoginFromWebView(url)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
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) {
|
||||
var alias: String? = null
|
||||
if (!reauthorizeAccount) {
|
||||
alias = appPreferences.temporaryClientCertAlias
|
||||
}
|
||||
val user = currentUserProvider.currentUser.blockingGet()
|
||||
if (TextUtils.isEmpty(alias) && user != null) {
|
||||
alias = user.clientCertificate
|
||||
}
|
||||
if (!TextUtils.isEmpty(alias)) {
|
||||
val finalAlias = alias
|
||||
Thread {
|
||||
try {
|
||||
val privateKey = KeyChain.getPrivateKey(applicationContext, finalAlias!!)
|
||||
val certificates = KeyChain.getCertificateChain(
|
||||
applicationContext,
|
||||
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(
|
||||
this@WebViewLoginActivity,
|
||||
{ chosenAlias: String? ->
|
||||
if (chosenAlias != null) {
|
||||
appPreferences!!.temporaryClientCertAlias = chosenAlias
|
||||
Thread {
|
||||
var privateKey: PrivateKey? = null
|
||||
try {
|
||||
privateKey = KeyChain.getPrivateKey(applicationContext, chosenAlias)
|
||||
val certificates = KeyChain.getCertificateChain(
|
||||
applicationContext,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("DiscouragedPrivateApi")
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "WebViewClientOnReceivedSslError")
|
||||
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
|
||||
try {
|
||||
trustManager.checkServerTrusted(arrayOf(cert), "generic")
|
||||
handler.proceed()
|
||||
} catch (exception: CertificateException) {
|
||||
eventBus.post(CertificateEvent(cert, trustManager, handler))
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
handler.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in super implementation")
|
||||
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()
|
||||
cookieManager.cookieStore.removeAll()
|
||||
|
||||
if (userManager.checkIfUserIsScheduledForDeletion(loginData.username!!, loginData.serverUrl!!)
|
||||
.blockingGet()
|
||||
) {
|
||||
Log.e(TAG, "Tried to add already existing user who is scheduled for deletion.")
|
||||
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
|
||||
// however the user is not yet deleted, just start AccountRemovalWorker again to make sure to delete it.
|
||||
startAccountRemovalWorkerAndRestartApp()
|
||||
} else if (userManager.checkIfUserExists(loginData.username!!, loginData.serverUrl!!)
|
||||
.blockingGet()
|
||||
) {
|
||||
if (reauthorizeAccount) {
|
||||
updateUserAndRestartApp(loginData)
|
||||
} else {
|
||||
Log.w(TAG, "It was tried to add an account that account already exists. Skipped user creation.")
|
||||
restartApp()
|
||||
}
|
||||
} else {
|
||||
startAccountVerification(loginData)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Login Data was null")
|
||||
restartApp()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAccountVerification(loginData: LoginData) {
|
||||
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 (loginData.serverUrl!!.startsWith("http://")) {
|
||||
protocol = "http://"
|
||||
} else if (loginData.serverUrl!!.startsWith("https://")) {
|
||||
protocol = "https://"
|
||||
}
|
||||
if (!TextUtils.isEmpty(protocol)) {
|
||||
bundle.putString(KEY_ORIGINAL_PROTOCOL, protocol)
|
||||
}
|
||||
val intent = Intent(context, AccountVerificationActivity::class.java)
|
||||
intent.putExtras(bundle)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun restartApp() {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun updateUserAndRestartApp(loginData: LoginData) {
|
||||
val currentUser = currentUserProvider.currentUser.blockingGet()
|
||||
if (currentUser != null) {
|
||||
currentUser.clientCertificate = appPreferences.temporaryClientCertAlias
|
||||
currentUser.token = loginData.token
|
||||
val rowsUpdated = userManager.updateOrCreateUser(currentUser).blockingGet()
|
||||
Log.d(TAG, "User rows updated: $rowsUpdated")
|
||||
restartApp()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAccountRemovalWorkerAndRestartApp() {
|
||||
val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build()
|
||||
WorkManager.getInstance(applicationContext).enqueue(accountRemovalWork)
|
||||
|
||||
WorkManager.getInstance(context).getWorkInfoByIdLiveData(accountRemovalWork.id)
|
||||
.observeForever { workInfo: WorkInfo? ->
|
||||
|
||||
when (workInfo?.state) {
|
||||
WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> {
|
||||
restartApp()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
public override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
dispose()
|
||||
}
|
||||
|
||||
init {
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
}
|
||||
|
||||
override val appBarLayoutType: AppBarLayoutType
|
||||
get() = AppBarLayoutType.EMPTY
|
||||
|
||||
companion object {
|
||||
private val TAG = WebViewLoginActivity::class.java.simpleName
|
||||
private const val PROTOCOL_SUFFIX = "://"
|
||||
private const val LOGIN_URL_DATA_KEY_VALUE_SEPARATOR = ":"
|
||||
private const val PARAMETER_COUNT = 3
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.account.data
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import com.nextcloud.talk.account.data.io.LocalLoginDataSource
|
||||
import com.nextcloud.talk.account.data.model.LoginCompletion
|
||||
import com.nextcloud.talk.account.data.model.LoginResponse
|
||||
import com.nextcloud.talk.account.data.network.NetworkLoginDataSource
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.URLDecoder
|
||||
|
||||
@Suppress("TooManyFunctions", "ReturnCount")
|
||||
class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLoginDataSource) {
|
||||
|
||||
companion object {
|
||||
val TAG: String = LoginRepository::class.java.simpleName
|
||||
private const val INTERVAL = 250L
|
||||
private const val HTTP_OK = 200
|
||||
private const val USER_KEY = "user:"
|
||||
private const val SERVER_KEY = "server:"
|
||||
private const val PASS_KEY = "password:"
|
||||
private const val PREFIX = "nc://login/"
|
||||
private const val MAX_ARGS = 3
|
||||
}
|
||||
|
||||
private var shouldReauthorizeUser = false
|
||||
private var shouldLoop = true
|
||||
|
||||
suspend fun pollLogin(response: LoginResponse): LoginCompletion? =
|
||||
withContext(Dispatchers.IO) {
|
||||
while (shouldLoop) {
|
||||
val loginData = network.performLoginFlowV2(response)
|
||||
if (loginData == null) {
|
||||
break
|
||||
}
|
||||
|
||||
if (loginData.status == HTTP_OK) {
|
||||
return@withContext loginData
|
||||
}
|
||||
|
||||
delay(INTERVAL) // No response yet, retry
|
||||
}
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for QR scanner
|
||||
*
|
||||
*/
|
||||
fun startLoginFlowFromQR(dataString: String, reAuth: Boolean = false): LoginCompletion? {
|
||||
shouldReauthorizeUser = reAuth
|
||||
|
||||
if (!dataString.startsWith(PREFIX)) {
|
||||
Log.e(TAG, "Invalid login URL detected")
|
||||
return null
|
||||
}
|
||||
|
||||
val data = dataString.removePrefix(PREFIX)
|
||||
val values = data.split('&')
|
||||
|
||||
if (values.size !in 1..MAX_ARGS) {
|
||||
Log.e(TAG, "Illegal number of login URL elements detected: ${values.size}")
|
||||
return null
|
||||
}
|
||||
|
||||
var server = ""
|
||||
var loginName = ""
|
||||
var appPassword = ""
|
||||
values.forEach { value ->
|
||||
when {
|
||||
value.startsWith(USER_KEY) -> {
|
||||
loginName = URLDecoder.decode(value.removePrefix(USER_KEY), "UTF-8")
|
||||
}
|
||||
|
||||
value.startsWith(PASS_KEY) -> {
|
||||
appPassword = URLDecoder.decode(value.removePrefix(PASS_KEY), "UTF-8")
|
||||
}
|
||||
|
||||
value.startsWith(SERVER_KEY) -> {
|
||||
server = URLDecoder.decode(value.removePrefix(SERVER_KEY), "UTF-8")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (server.isNotEmpty() && loginName.isNotEmpty() && appPassword.isNotEmpty()) {
|
||||
LoginCompletion(HTTP_OK, server, loginName, appPassword)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point to the login process
|
||||
*/
|
||||
suspend fun startLoginFlow(baseUrl: String, reAuth: Boolean = false): LoginResponse? =
|
||||
withContext(Dispatchers.IO) {
|
||||
shouldReauthorizeUser = reAuth
|
||||
val response = network.anonymouslyPostLoginRequest(baseUrl)
|
||||
return@withContext response
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends normal login process by canceling the polling
|
||||
*/
|
||||
fun cancelLoginFlow() {
|
||||
shouldLoop = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns bundle if user is not scheduled for deletion or doesn't already exist, null otherwise
|
||||
*/
|
||||
fun parseAndLogin(loginData: LoginCompletion): Bundle? {
|
||||
if (local.checkIfUserIsScheduledForDeletion(loginData)) {
|
||||
// however the user is not yet deleted, just start AccountRemovalWorker again to make sure to delete it.
|
||||
local.startAccountRemovalWorker()
|
||||
return null
|
||||
} else if (local.checkIfUserExists(loginData)) {
|
||||
if (shouldReauthorizeUser) {
|
||||
local.updateUser(loginData)
|
||||
} else {
|
||||
Log.w(TAG, "Tried to add an account that account already exists. Skipped user creation.")
|
||||
}
|
||||
|
||||
return null
|
||||
} else {
|
||||
return startAccountVerification(loginData)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAccountVerification(loginData: LoginCompletion): Bundle {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_USERNAME, loginData.loginName)
|
||||
bundle.putString(KEY_TOKEN, loginData.appPassword)
|
||||
bundle.putString(KEY_BASE_URL, loginData.server)
|
||||
var protocol = ""
|
||||
if (loginData.server.startsWith("http://")) {
|
||||
protocol = "http://"
|
||||
} else if (loginData.server.startsWith("https://")) {
|
||||
protocol = "https://"
|
||||
}
|
||||
if (!TextUtils.isEmpty(protocol)) {
|
||||
bundle.putString(KEY_ORIGINAL_PROTOCOL, protocol)
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.account.data.io
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import com.nextcloud.talk.account.data.model.LoginCompletion
|
||||
import com.nextcloud.talk.jobs.AccountRemovalWorker
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
|
||||
// local datasource for communicating with room through account manager
|
||||
// crucial for making sure the login process interacts with the db as expected.
|
||||
class LocalLoginDataSource(val userManager: UserManager, val appPreferences: AppPreferences, val context: Context) {
|
||||
|
||||
fun updateUser(loginData: LoginCompletion) {
|
||||
val currentUser = userManager.currentUser.blockingGet()
|
||||
if (currentUser != null) {
|
||||
currentUser.clientCertificate = appPreferences.temporaryClientCertAlias
|
||||
currentUser.token = loginData.appPassword
|
||||
userManager.updateOrCreateUser(currentUser)
|
||||
}
|
||||
}
|
||||
|
||||
fun startAccountRemovalWorker(): LiveData<WorkInfo?> {
|
||||
val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build()
|
||||
WorkManager.getInstance(context).enqueue(accountRemovalWork)
|
||||
|
||||
return WorkManager.getInstance(context).getWorkInfoByIdLiveData(accountRemovalWork.id)
|
||||
}
|
||||
|
||||
fun checkIfUserIsScheduledForDeletion(data: LoginCompletion): Boolean =
|
||||
userManager.checkIfUserIsScheduledForDeletion(data.loginName, data.server).blockingGet()
|
||||
|
||||
fun checkIfUserExists(data: LoginCompletion): Boolean =
|
||||
userManager.checkIfUserExists(data.loginName, data.server).blockingGet()
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.account.data.model
|
||||
|
||||
data class LoginResponse(val token: String, val pollUrl: String, val loginUrl: String)
|
||||
|
||||
data class LoginCompletion(val status: Int, val server: String, val loginName: String, val appPassword: String)
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.account.data.network
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import com.nextcloud.talk.account.data.model.LoginCompletion
|
||||
import com.nextcloud.talk.account.data.model.LoginResponse
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import java.io.IOException
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
|
||||
// This class handles the network and polling logic in isolation, which makes it easier to test
|
||||
// Login and Authentication is critical, thus it needs to be working properly.
|
||||
class NetworkLoginDataSource(val okHttpClient: OkHttpClient) {
|
||||
|
||||
companion object {
|
||||
val TAG: String = NetworkLoginDataSource::class.java.simpleName
|
||||
}
|
||||
|
||||
fun anonymouslyPostLoginRequest(baseUrl: String): LoginResponse? {
|
||||
val url = "$baseUrl/index.php/login/v2"
|
||||
var result: LoginResponse? = null
|
||||
runCatching {
|
||||
val response = getResponseOfAnonymouslyPostLoginRequest(url)
|
||||
val jsonObject: JsonObject = JsonParser.parseString(response).asJsonObject
|
||||
val loginUrl: String = getLoginUrl(jsonObject)
|
||||
val token = jsonObject.getAsJsonObject("poll").get("token").asString
|
||||
val pollUrl = jsonObject.getAsJsonObject("poll").get("endpoint").asString
|
||||
result = LoginResponse(token, pollUrl, loginUrl)
|
||||
}.getOrElse { e ->
|
||||
when (e) {
|
||||
is SSLHandshakeException,
|
||||
is NullPointerException,
|
||||
is IOException -> {
|
||||
Log.e(TAG, "Error caught at anonymouslyPostLoginRequest: $e")
|
||||
}
|
||||
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun getResponseOfAnonymouslyPostLoginRequest(url: String): String? {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(FormBody.Builder().build())
|
||||
.addHeader("Clear-Site-Data", "cookies")
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Unexpected code $response")
|
||||
}
|
||||
return response.body?.string()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLoginUrl(response: JsonObject): String {
|
||||
var result: String? = response.get("login").asString
|
||||
if (result == null) {
|
||||
result = ""
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun performLoginFlowV2(response: LoginResponse): LoginCompletion? {
|
||||
val requestBody: RequestBody = FormBody.Builder()
|
||||
.add("token", response.token)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(response.pollUrl)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
var result: LoginCompletion? = null
|
||||
runCatching {
|
||||
okHttpClient.newCall(request).execute()
|
||||
.use { response ->
|
||||
val status: Int = response.code
|
||||
val responseBody = response.body?.string()
|
||||
|
||||
result = if (response.isSuccessful && responseBody?.isNotEmpty() == true) {
|
||||
val jsonObject = JsonParser.parseString(responseBody).asJsonObject
|
||||
val server: String = jsonObject.get("server").asString
|
||||
val loginName: String = jsonObject.get("loginName").asString
|
||||
val appPassword: String = jsonObject.get("appPassword").asString
|
||||
|
||||
LoginCompletion(status, server, loginName, appPassword)
|
||||
} else {
|
||||
LoginCompletion(status, "", "", "")
|
||||
}
|
||||
}
|
||||
}.getOrElse { e ->
|
||||
when (e) {
|
||||
is NullPointerException,
|
||||
is SSLHandshakeException,
|
||||
is IllegalStateException,
|
||||
is IOException -> {
|
||||
Log.e(TAG, "Error caught at performLoginFlowV2: $e")
|
||||
}
|
||||
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Your Name <your@email.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.account.viewmodels
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.nextcloud.talk.account.data.LoginRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRepository) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = BrowserLoginActivityViewModel::class.java.simpleName
|
||||
}
|
||||
|
||||
sealed class InitialLoginViewState {
|
||||
data object None : InitialLoginViewState()
|
||||
data class InitialLoginRequestSuccess(val loginUrl: String) : InitialLoginViewState()
|
||||
data object InitialLoginRequestError : InitialLoginViewState()
|
||||
}
|
||||
|
||||
private val _initialLoginRequestState = MutableStateFlow<InitialLoginViewState>(InitialLoginViewState.None)
|
||||
val initialLoginRequestState: StateFlow<InitialLoginViewState> = _initialLoginRequestState
|
||||
|
||||
sealed class PostLoginViewState {
|
||||
data object None : PostLoginViewState()
|
||||
data object PostLoginRestartApp : PostLoginViewState()
|
||||
data object PostLoginError : PostLoginViewState()
|
||||
data class PostLoginContinue(val data: Bundle) : PostLoginViewState()
|
||||
}
|
||||
|
||||
private val _postLoginState = MutableStateFlow<PostLoginViewState>(PostLoginViewState.None)
|
||||
val postLoginState: StateFlow<PostLoginViewState> = _postLoginState
|
||||
|
||||
fun loginNormally(baseUrl: String, reAuth: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
val response = repository.startLoginFlow(baseUrl, reAuth)
|
||||
|
||||
if (response == null) {
|
||||
_initialLoginRequestState.value = InitialLoginViewState.InitialLoginRequestError
|
||||
return@launch
|
||||
}
|
||||
|
||||
_initialLoginRequestState.value =
|
||||
InitialLoginViewState.InitialLoginRequestSuccess(response.loginUrl)
|
||||
|
||||
val loginCompletionResponse = repository.pollLogin(response)
|
||||
|
||||
if (loginCompletionResponse == null) {
|
||||
_postLoginState.value = PostLoginViewState.PostLoginError
|
||||
return@launch
|
||||
}
|
||||
|
||||
val bundle = repository.parseAndLogin(loginCompletionResponse)
|
||||
if (bundle == null) {
|
||||
_postLoginState.value = PostLoginViewState.PostLoginRestartApp
|
||||
return@launch
|
||||
}
|
||||
|
||||
_postLoginState.value = PostLoginViewState.PostLoginContinue(bundle)
|
||||
}
|
||||
}
|
||||
|
||||
fun loginWithQR(dataString: String, reAuth: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
val loginCompletionResponse = repository.startLoginFlowFromQR(dataString, reAuth)
|
||||
if (loginCompletionResponse == null) {
|
||||
_postLoginState.value = PostLoginViewState.PostLoginError
|
||||
return@launch
|
||||
}
|
||||
|
||||
val bundle = repository.parseAndLogin(loginCompletionResponse)
|
||||
if (bundle == null) {
|
||||
_postLoginState.value = PostLoginViewState.PostLoginRestartApp
|
||||
return@launch
|
||||
}
|
||||
|
||||
_postLoginState.value = PostLoginViewState.PostLoginContinue(bundle)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelLogin() = repository.cancelLoginFlow()
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2016 BlueLine Labs, Inc.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
package com.nextcloud.talk.activities;
|
||||
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
|
||||
public interface ActionBarProvider {
|
||||
ActionBar getSupportActionBar();
|
||||
}
|
||||
295
app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt
Normal file
295
app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2023 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import autodagger.AutoInjector
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.account.AccountVerificationActivity
|
||||
import com.nextcloud.talk.account.ServerSelectionActivity
|
||||
import com.nextcloud.talk.account.SwitchAccountActivity
|
||||
import com.nextcloud.talk.account.WebViewLoginActivity
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.events.CertificateEvent
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.DisplayUtils
|
||||
import com.nextcloud.talk.utils.FileViewerUtils
|
||||
import com.nextcloud.talk.utils.UriUtils
|
||||
import com.nextcloud.talk.utils.adjustUIForAPILevel35
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys
|
||||
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import com.nextcloud.talk.utils.ssl.TrustManager
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import java.security.cert.CertificateParsingException
|
||||
import java.security.cert.X509Certificate
|
||||
import java.text.DateFormat
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
open class BaseActivity : AppCompatActivity() {
|
||||
|
||||
enum class AppBarLayoutType {
|
||||
TOOLBAR,
|
||||
SEARCH_BAR,
|
||||
EMPTY
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var eventBus: EventBus
|
||||
|
||||
@Inject
|
||||
lateinit var appPreferences: AppPreferences
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var currentUserProvider: CurrentUserProviderNew
|
||||
|
||||
open val appBarLayoutType: AppBarLayoutType
|
||||
get() = AppBarLayoutType.TOOLBAR
|
||||
|
||||
open val view: View?
|
||||
get() = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
|
||||
adjustUIForAPILevel35()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
cleanTempCertPreference()
|
||||
}
|
||||
|
||||
public override fun onStart() {
|
||||
super.onStart()
|
||||
eventBus.register(this)
|
||||
}
|
||||
|
||||
public override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
if (appPreferences.isKeyboardIncognito) {
|
||||
val viewGroup = (findViewById<View>(android.R.id.content) as ViewGroup).getChildAt(0) as ViewGroup
|
||||
disableKeyboardPersonalisedLearning(viewGroup)
|
||||
}
|
||||
|
||||
if (appPreferences.isScreenSecured || appPreferences.isScreenLocked) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
public override fun onStop() {
|
||||
super.onStop()
|
||||
eventBus.unregister(this)
|
||||
}
|
||||
|
||||
/*
|
||||
* May be aligned with android-common lib in the future: .../ui/util/extensions/AppCompatActivityExtensions.kt
|
||||
*/
|
||||
fun initSystemBars() {
|
||||
val decorView = window.decorView
|
||||
decorView.setOnApplyWindowInsetsListener { view, insets ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
val systemBars = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or
|
||||
WindowInsetsCompat.Type.displayCutout()
|
||||
)
|
||||
val color = ResourcesCompat.getColor(resources, R.color.bg_default, context.theme)
|
||||
view.setBackgroundColor(color)
|
||||
view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
|
||||
} else {
|
||||
colorizeStatusBar()
|
||||
colorizeNavigationBar()
|
||||
}
|
||||
insets
|
||||
}
|
||||
ViewCompat.requestApplyInsets(decorView)
|
||||
}
|
||||
|
||||
open fun colorizeStatusBar() {
|
||||
if (resources != null) {
|
||||
if (appBarLayoutType == AppBarLayoutType.SEARCH_BAR) {
|
||||
viewThemeUtils.platform.resetStatusBar(this)
|
||||
} else {
|
||||
viewThemeUtils.platform.themeStatusBar(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun colorizeNavigationBar() {
|
||||
if (resources != null) {
|
||||
DisplayUtils.applyColorToNavigationBar(
|
||||
this.window,
|
||||
ResourcesCompat.getColor(resources, R.color.bg_default, null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun disableKeyboardPersonalisedLearning(viewGroup: ViewGroup) {
|
||||
var view: View?
|
||||
var editText: EditText
|
||||
for (i in 0 until viewGroup.childCount) {
|
||||
view = viewGroup.getChildAt(i)
|
||||
if (view is EditText) {
|
||||
editText = view
|
||||
editText.imeOptions = editText.imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
|
||||
} else if (view is ViewGroup) {
|
||||
disableKeyboardPersonalisedLearning(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("Detekt.NestedBlockDepth")
|
||||
private fun showCertificateDialog(
|
||||
cert: X509Certificate,
|
||||
trustManager: TrustManager,
|
||||
sslErrorHandler: SslErrorHandler?
|
||||
) {
|
||||
val formatter = DateFormat.getDateInstance(DateFormat.LONG)
|
||||
val validFrom = formatter.format(cert.notBefore)
|
||||
val validUntil = formatter.format(cert.notAfter)
|
||||
|
||||
val issuedBy = cert.issuerDN.toString()
|
||||
val issuedFor: String
|
||||
|
||||
try {
|
||||
if (cert.subjectAlternativeNames != null) {
|
||||
val stringBuilder = StringBuilder()
|
||||
for (o in cert.subjectAlternativeNames) {
|
||||
val list = o as List<*>
|
||||
val type = list[0] as Int
|
||||
if (type == 2) {
|
||||
val name = list[1] as String
|
||||
stringBuilder.append("[").append(type).append("]").append(name).append(" ")
|
||||
}
|
||||
}
|
||||
issuedFor = stringBuilder.toString()
|
||||
} else {
|
||||
issuedFor = cert.subjectDN.name
|
||||
}
|
||||
|
||||
@SuppressLint("StringFormatMatches")
|
||||
val dialogText = String.format(
|
||||
resources.getString(R.string.nc_certificate_dialog_text),
|
||||
issuedBy,
|
||||
issuedFor,
|
||||
validFrom,
|
||||
validUntil
|
||||
)
|
||||
|
||||
val dialogBuilder = MaterialAlertDialogBuilder(this).setIcon(
|
||||
viewThemeUtils.dialog.colorMaterialAlertDialogIcon(
|
||||
context,
|
||||
R.drawable.ic_security_white_24dp
|
||||
)
|
||||
).setTitle(R.string.nc_certificate_dialog_title)
|
||||
.setMessage(dialogText)
|
||||
.setPositiveButton(R.string.nc_yes) { _, _ ->
|
||||
trustManager.addCertInTrustStore(cert)
|
||||
sslErrorHandler?.proceed()
|
||||
}.setNegativeButton(R.string.nc_no) { _, _ ->
|
||||
sslErrorHandler?.cancel()
|
||||
}
|
||||
|
||||
viewThemeUtils.dialog.colorMaterialAlertDialogBackground(context, dialogBuilder)
|
||||
|
||||
val dialog = dialogBuilder.show()
|
||||
|
||||
viewThemeUtils.platform.colorTextButtons(
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE),
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
|
||||
)
|
||||
} catch (e: CertificateParsingException) {
|
||||
Log.d(TAG, "Failed to parse the certificate")
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanTempCertPreference() {
|
||||
val temporaryClassNames: MutableList<String> = ArrayList()
|
||||
temporaryClassNames.add(ServerSelectionActivity::class.java.name)
|
||||
temporaryClassNames.add(AccountVerificationActivity::class.java.name)
|
||||
temporaryClassNames.add(WebViewLoginActivity::class.java.name)
|
||||
temporaryClassNames.add(SwitchAccountActivity::class.java.name)
|
||||
if (!temporaryClassNames.contains(javaClass.name)) {
|
||||
appPreferences.removeTemporaryClientCertAlias()
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onMessageEvent(event: CertificateEvent) {
|
||||
showCertificateDialog(event.x509Certificate, event.trustManager, event.sslErrorHandler)
|
||||
}
|
||||
|
||||
override fun startActivity(intent: Intent) {
|
||||
val user = currentUserProvider.currentUser.blockingGet()
|
||||
if (intent.data != null && TextUtils.equals(intent.action, Intent.ACTION_VIEW)) {
|
||||
val uri = intent.data.toString()
|
||||
if (user?.baseUrl != null && uri.startsWith(user.baseUrl!!)) {
|
||||
if (UriUtils.isInstanceInternalFileShareUrl(user.baseUrl!!, uri)) {
|
||||
// https://cloud.nextcloud.com/f/41
|
||||
val fileViewerUtils = FileViewerUtils(applicationContext, user)
|
||||
fileViewerUtils.openFileInFilesApp(uri, UriUtils.extractInstanceInternalFileShareFileId(uri))
|
||||
} else if (UriUtils.isInstanceInternalFileUrl(user.baseUrl!!, uri)) {
|
||||
// https://cloud.nextcloud.com/apps/files/?dir=/Engineering&fileid=41
|
||||
val fileViewerUtils = FileViewerUtils(applicationContext, user)
|
||||
fileViewerUtils.openFileInFilesApp(uri, UriUtils.extractInstanceInternalFileFileId(uri))
|
||||
} else if (UriUtils.isInstanceInternalFileUrlNew(user.baseUrl!!, uri)) {
|
||||
// https://cloud.nextcloud.com/apps/files/?dir=/Engineering&fileid=41
|
||||
val fileViewerUtils = FileViewerUtils(applicationContext, user)
|
||||
fileViewerUtils.openFileInFilesApp(uri, UriUtils.extractInstanceInternalFileFileIdNew(uri))
|
||||
} else if (UriUtils.isInstanceInternalTalkUrl(user.baseUrl!!, uri)) {
|
||||
// https://cloud.nextcloud.com/call/123456789
|
||||
val bundle = Bundle()
|
||||
bundle.putString(BundleKeys.KEY_ROOM_TOKEN, UriUtils.extractRoomTokenFromTalkUrl(uri))
|
||||
val chatIntent = Intent(context, ChatActivity::class.java)
|
||||
chatIntent.putExtras(bundle)
|
||||
chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
startActivity(chatIntent)
|
||||
} else {
|
||||
super.startActivity(intent)
|
||||
}
|
||||
} else {
|
||||
super.startActivity(intent)
|
||||
}
|
||||
} else {
|
||||
super.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = BaseActivity::class.java.simpleName
|
||||
}
|
||||
}
|
||||
3355
app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt
Normal file
3355
app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,149 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.activities;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.AppOpsManager;
|
||||
import android.app.KeyguardManager;
|
||||
import android.app.PictureInPictureParams;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.util.Rational;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import com.nextcloud.talk.BuildConfig;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
|
||||
public abstract class CallBaseActivity extends BaseActivity {
|
||||
|
||||
public static final String TAG = "CallBaseActivity";
|
||||
|
||||
public PictureInPictureParams.Builder mPictureInPictureParamsBuilder;
|
||||
public Boolean isInPipMode = Boolean.FALSE;
|
||||
long onCreateTime;
|
||||
|
||||
|
||||
private OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) {
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
if (isPipModePossible()) {
|
||||
enterPipMode();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
onCreateTime = System.currentTimeMillis();
|
||||
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
dismissKeyguard();
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
|
||||
if (isPipModePossible()) {
|
||||
mPictureInPictureParamsBuilder = new PictureInPictureParams.Builder();
|
||||
}
|
||||
|
||||
getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback);
|
||||
}
|
||||
|
||||
public void hideNavigationIfNoPipAvailable(){
|
||||
if (!isPipModePossible()) {
|
||||
getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
|
||||
suppressFitsSystemWindows();
|
||||
}
|
||||
}
|
||||
|
||||
void dismissKeyguard() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(true);
|
||||
setTurnScreenOn(true);
|
||||
KeyguardManager keyguardManager = (KeyguardManager) getSystemService(KEYGUARD_SERVICE);
|
||||
keyguardManager.requestDismissKeyguard(this, null);
|
||||
} else {
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
|
||||
}
|
||||
}
|
||||
|
||||
void enableKeyguard() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(false);
|
||||
} else {
|
||||
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
|
||||
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
|
||||
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop() {
|
||||
super.onStop();
|
||||
if (isInPipMode) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onUserLeaveHint() {
|
||||
super.onUserLeaveHint();
|
||||
long onUserLeaveHintTime = System.currentTimeMillis();
|
||||
long diff = onUserLeaveHintTime - onCreateTime;
|
||||
Log.d(TAG, "onUserLeaveHintTime - onCreateTime: " + diff);
|
||||
|
||||
if (diff < 3000) {
|
||||
Log.d(TAG, "enterPipMode skipped");
|
||||
} else {
|
||||
enterPipMode();
|
||||
}
|
||||
}
|
||||
|
||||
void enterPipMode() {
|
||||
enableKeyguard();
|
||||
if (isPipModePossible()) {
|
||||
Rational pipRatio = new Rational(300, 500);
|
||||
mPictureInPictureParamsBuilder.setAspectRatio(pipRatio);
|
||||
enterPictureInPictureMode(mPictureInPictureParamsBuilder.build());
|
||||
} else {
|
||||
// we don't support other solutions than PIP to have a call in the background.
|
||||
// If PIP is not available the call is ended when user presses the home button.
|
||||
Log.d(TAG, "Activity was finished because PIP is not available.");
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
boolean isPipModePossible() {
|
||||
boolean deviceHasPipFeature = getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
|
||||
|
||||
AppOpsManager appOpsManager = (AppOpsManager) getSystemService(Context.APP_OPS_SERVICE);
|
||||
boolean isPipFeatureGranted = appOpsManager.checkOpNoThrow(
|
||||
AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
|
||||
android.os.Process.myUid(),
|
||||
BuildConfig.APPLICATION_ID) == AppOpsManager.MODE_ALLOWED;
|
||||
return deviceHasPipFeature && isPipFeatureGranted;
|
||||
}
|
||||
|
||||
public abstract void updateUiForPipMode();
|
||||
|
||||
public abstract void updateUiForNormalMode();
|
||||
|
||||
public abstract void suppressFitsSystemWindows();
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.activities
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
enum class CallStatus : Parcelable {
|
||||
CONNECTING,
|
||||
CALLING_TIMEOUT,
|
||||
JOINED,
|
||||
IN_CONVERSATION,
|
||||
RECONNECTING,
|
||||
OFFLINE,
|
||||
LEAVING,
|
||||
PUBLISHER_FAILED
|
||||
}
|
||||
289
app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt
Normal file
289
app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2023 Ezhil Shanmugham <ezhil56x.contact@gmail.com>
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <infoi@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.activities
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import autodagger.AutoInjector
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.account.ServerSelectionActivity
|
||||
import com.nextcloud.talk.account.WebViewLoginActivity
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.conversationlist.ConversationsListActivity
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.ActivityMainBinding
|
||||
import com.nextcloud.talk.invitation.InvitationsActivity
|
||||
import com.nextcloud.talk.lock.LockedActivity
|
||||
import com.nextcloud.talk.models.json.conversations.RoomOverall
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.ClosedInterfaceImpl
|
||||
import com.nextcloud.talk.utils.SecurityUtils
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
|
||||
import io.reactivex.Observer
|
||||
import io.reactivex.SingleObserver
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class MainActivity :
|
||||
BaseActivity(),
|
||||
ActionBarProvider {
|
||||
|
||||
lateinit var binding: ActivityMainBinding
|
||||
|
||||
@Inject
|
||||
lateinit var ncApi: NcApi
|
||||
|
||||
@Inject
|
||||
lateinit var userManager: UserManager
|
||||
|
||||
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Log.d(TAG, "onCreate: Activity: " + System.identityHashCode(this).toString())
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
lockScreenIfConditionsApply()
|
||||
}
|
||||
})
|
||||
|
||||
// Set the default theme to replace the launch screen theme.
|
||||
setTheme(R.style.AppTheme)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
|
||||
|
||||
setSupportActionBar(binding.toolbar)
|
||||
|
||||
handleIntent(intent)
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
|
||||
}
|
||||
|
||||
fun lockScreenIfConditionsApply() {
|
||||
val keyguardManager = getSystemService(KEYGUARD_SERVICE) as KeyguardManager
|
||||
if (keyguardManager.isKeyguardSecure && appPreferences.isScreenLocked) {
|
||||
if (!SecurityUtils.checkIfWeAreAuthenticated(appPreferences.screenLockTimeout)) {
|
||||
val lockIntent = Intent(context, LockedActivity::class.java)
|
||||
startActivity(lockIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchServerSelection() {
|
||||
if (isBrandingUrlSet()) {
|
||||
val intent = Intent(context, WebViewLoginActivity::class.java)
|
||||
val bundle = Bundle()
|
||||
bundle.putString(BundleKeys.KEY_BASE_URL, resources.getString(R.string.weblogin_url))
|
||||
intent.putExtras(bundle)
|
||||
startActivity(intent)
|
||||
} else {
|
||||
val intent = Intent(context, ServerSelectionActivity::class.java)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBrandingUrlSet() = !TextUtils.isEmpty(resources.getString(R.string.weblogin_url))
|
||||
|
||||
override fun onStart() {
|
||||
Log.d(TAG, "onStart: Activity: " + System.identityHashCode(this).toString())
|
||||
super.onStart()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
Log.d(TAG, "onResume: Activity: " + System.identityHashCode(this).toString())
|
||||
super.onResume()
|
||||
|
||||
if (appPreferences.isScreenLocked) {
|
||||
SecurityUtils.createKey(appPreferences.screenLockTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
Log.d(TAG, "onPause: Activity: " + System.identityHashCode(this).toString())
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
Log.d(TAG, "onStop: Activity: " + System.identityHashCode(this).toString())
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
private fun openConversationList() {
|
||||
val intent = Intent(this, ConversationsListActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
intent.putExtras(Bundle())
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun handleActionFromContact(intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_VIEW && intent.data != null) {
|
||||
val cursor = contentResolver.query(intent.data!!, null, null, null, null)
|
||||
|
||||
var userId = ""
|
||||
if (cursor != null) {
|
||||
if (cursor.moveToFirst()) {
|
||||
// userId @ server
|
||||
userId = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Data.DATA1))
|
||||
}
|
||||
|
||||
cursor.close()
|
||||
}
|
||||
|
||||
when (intent.type) {
|
||||
"vnd.android.cursor.item/vnd.com.nextcloud.talk2.chat" -> {
|
||||
val user = userId.substringBeforeLast("@")
|
||||
val baseUrl = userId.substringAfterLast("@")
|
||||
|
||||
if (currentUserProvider.currentUser.blockingGet()?.baseUrl!!.endsWith(baseUrl) == true) {
|
||||
startConversation(user)
|
||||
} else {
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
R.string.nc_phone_book_integration_account_not_found,
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startConversation(userId: String) {
|
||||
val roomType = "1"
|
||||
|
||||
val currentUser = currentUserProvider.currentUser.blockingGet()
|
||||
|
||||
val apiVersion = ApiUtils.getConversationApiVersion(currentUser, intArrayOf(ApiUtils.API_V4, 1))
|
||||
val credentials = ApiUtils.getCredentials(currentUser?.username, currentUser?.token)
|
||||
val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom(
|
||||
version = apiVersion,
|
||||
baseUrl = currentUser?.baseUrl!!,
|
||||
roomType = roomType,
|
||||
invite = userId
|
||||
)
|
||||
|
||||
ncApi.createRoom(
|
||||
credentials,
|
||||
retrofitBucket.url,
|
||||
retrofitBucket.queryMap
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(object : Observer<RoomOverall> {
|
||||
override fun onSubscribe(d: Disposable) {
|
||||
// unused atm
|
||||
}
|
||||
|
||||
override fun onNext(roomOverall: RoomOverall) {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token)
|
||||
|
||||
val chatIntent = Intent(context, ChatActivity::class.java)
|
||||
chatIntent.putExtras(bundle)
|
||||
startActivity(chatIntent)
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
// unused atm
|
||||
}
|
||||
|
||||
override fun onComplete() {
|
||||
// unused atm
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
Log.d(TAG, "onNewIntent Activity: " + System.identityHashCode(this).toString())
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
handleActionFromContact(intent)
|
||||
|
||||
val internalUserId = intent.extras?.getLong(BundleKeys.KEY_INTERNAL_USER_ID)
|
||||
|
||||
var user: User? = null
|
||||
if (internalUserId != null) {
|
||||
user = userManager.getUserWithId(internalUserId).blockingGet()
|
||||
}
|
||||
|
||||
if (user != null && userManager.setUserAsActive(user).blockingGet()) {
|
||||
if (intent.hasExtra(BundleKeys.KEY_REMOTE_TALK_SHARE)) {
|
||||
if (intent.getBooleanExtra(BundleKeys.KEY_REMOTE_TALK_SHARE, false)) {
|
||||
val invitationsIntent = Intent(this, InvitationsActivity::class.java)
|
||||
startActivity(invitationsIntent)
|
||||
}
|
||||
} else {
|
||||
val chatIntent = Intent(context, ChatActivity::class.java)
|
||||
chatIntent.putExtras(intent.extras!!)
|
||||
startActivity(chatIntent)
|
||||
}
|
||||
} else {
|
||||
userManager.users.subscribe(object : SingleObserver<List<User>> {
|
||||
override fun onSubscribe(d: Disposable) {
|
||||
// unused atm
|
||||
}
|
||||
|
||||
override fun onSuccess(users: List<User>) {
|
||||
if (users.isNotEmpty()) {
|
||||
ClosedInterfaceImpl().setUpPushTokenRegistration()
|
||||
runOnUiThread {
|
||||
openConversationList()
|
||||
}
|
||||
} else {
|
||||
runOnUiThread {
|
||||
launchServerSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
Log.e(TAG, "Error loading existing users", e)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.resources.getString(R.string.nc_common_error_sorry),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = MainActivity::class.java.simpleName
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,423 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2021 Stefan Niedermann <info@niedermann.it>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.activities;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.hardware.camera2.CameraMetadata;
|
||||
import android.hardware.camera2.CaptureRequest;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.util.Log;
|
||||
import android.util.Size;
|
||||
import android.view.OrientationEventListener;
|
||||
import android.view.ScaleGestureDetector;
|
||||
import android.view.Surface;
|
||||
import android.view.View;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.nextcloud.talk.R;
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication;
|
||||
import com.nextcloud.talk.databinding.ActivityTakePictureBinding;
|
||||
import com.nextcloud.talk.models.TakePictureViewModel;
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils;
|
||||
import com.nextcloud.talk.utils.BitmapShrinker;
|
||||
import com.nextcloud.talk.utils.FileUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.camera.camera2.interop.Camera2Interop;
|
||||
import androidx.camera.core.AspectRatio;
|
||||
import androidx.camera.core.Camera;
|
||||
import androidx.camera.core.ImageCapture;
|
||||
import androidx.camera.core.ImageCaptureException;
|
||||
import androidx.camera.core.Preview;
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.exifinterface.media.ExifInterface;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import autodagger.AutoInjector;
|
||||
|
||||
import static com.nextcloud.talk.utils.Mimetype.IMAGE_JPEG;
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication.class)
|
||||
public class TakePhotoActivity extends AppCompatActivity {
|
||||
private static final String TAG = TakePhotoActivity.class.getSimpleName();
|
||||
|
||||
private static final float MAX_SCALE = 6.0f;
|
||||
private static final float MEDIUM_SCALE = 2.45f;
|
||||
|
||||
private ActivityTakePictureBinding binding;
|
||||
private TakePictureViewModel viewModel;
|
||||
|
||||
private ListenableFuture<ProcessCameraProvider> cameraProviderFuture;
|
||||
private OrientationEventListener orientationEventListener;
|
||||
|
||||
private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH-mm-ss", Locale.ROOT);
|
||||
|
||||
private Camera camera;
|
||||
|
||||
@Inject
|
||||
ViewThemeUtils viewThemeUtils;
|
||||
|
||||
private OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) {
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
Uri uri = (Uri) binding.photoPreview.getTag();
|
||||
|
||||
if (uri != null) {
|
||||
File photoFile = new File(uri.getPath());
|
||||
if (!photoFile.delete()) {
|
||||
Log.w(TAG, "Error deleting temp camera image");
|
||||
}
|
||||
binding.photoPreview.setTag(null);
|
||||
}
|
||||
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this);
|
||||
|
||||
binding = ActivityTakePictureBinding.inflate(getLayoutInflater());
|
||||
viewModel = new ViewModelProvider(this).get(TakePictureViewModel.class);
|
||||
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
viewThemeUtils.material.themeFAB(binding.takePhoto);
|
||||
viewThemeUtils.material.colorMaterialButtonPrimaryFilled(binding.send);
|
||||
|
||||
cameraProviderFuture = ProcessCameraProvider.getInstance(this);
|
||||
cameraProviderFuture.addListener(() -> {
|
||||
try {
|
||||
final ProcessCameraProvider cameraProvider = cameraProviderFuture.get();
|
||||
|
||||
camera = cameraProvider.bindToLifecycle(
|
||||
this,
|
||||
viewModel.getCameraSelector(),
|
||||
getImageCapture(
|
||||
viewModel.isCropEnabled().getValue(), viewModel.isLowResolutionEnabled().getValue()),
|
||||
getPreview(viewModel.isCropEnabled().getValue()));
|
||||
|
||||
viewModel.getTorchToggleButtonImageResource()
|
||||
.observe(
|
||||
this,
|
||||
res -> binding.toggleTorch.setIcon(ContextCompat.getDrawable(this, res)));
|
||||
viewModel.isTorchEnabled()
|
||||
.observe(
|
||||
this,
|
||||
enabled -> camera.getCameraControl().enableTorch(viewModel.isTorchEnabled().getValue()));
|
||||
binding.toggleTorch.setOnClickListener((v) -> viewModel.toggleTorchEnabled());
|
||||
|
||||
viewModel.getCropToggleButtonImageResource()
|
||||
.observe(
|
||||
this,
|
||||
res -> binding.toggleCrop.setIcon(ContextCompat.getDrawable(this, res)));
|
||||
viewModel.isCropEnabled()
|
||||
.observe(
|
||||
this,
|
||||
enabled -> {
|
||||
cameraProvider.unbindAll();
|
||||
camera = cameraProvider.bindToLifecycle(
|
||||
this,
|
||||
viewModel.getCameraSelector(),
|
||||
getImageCapture(
|
||||
viewModel.isCropEnabled().getValue(), viewModel.isLowResolutionEnabled().getValue()),
|
||||
getPreview(viewModel.isCropEnabled().getValue()));
|
||||
camera.getCameraControl().enableTorch(viewModel.isTorchEnabled().getValue());
|
||||
});
|
||||
binding.toggleCrop.setOnClickListener((v) -> viewModel.toggleCropEnabled());
|
||||
|
||||
viewModel.getLowResolutionToggleButtonImageResource()
|
||||
.observe(
|
||||
this,
|
||||
res -> binding.toggleLowres.setIcon(ContextCompat.getDrawable(this, res)));
|
||||
viewModel.isLowResolutionEnabled()
|
||||
.observe(
|
||||
this,
|
||||
enabled -> {
|
||||
cameraProvider.unbindAll();
|
||||
camera = cameraProvider.bindToLifecycle(
|
||||
this,
|
||||
viewModel.getCameraSelector(),
|
||||
getImageCapture(
|
||||
viewModel.isCropEnabled().getValue(), viewModel.isLowResolutionEnabled().getValue()),
|
||||
getPreview(viewModel.isCropEnabled().getValue()));
|
||||
camera.getCameraControl().enableTorch(viewModel.isTorchEnabled().getValue());
|
||||
});
|
||||
binding.toggleLowres.setOnClickListener((v) -> viewModel.toggleLowResolutionEnabled());
|
||||
|
||||
binding.switchCamera.setOnClickListener((v) -> {
|
||||
viewModel.toggleCameraSelector();
|
||||
cameraProvider.unbindAll();
|
||||
camera = cameraProvider.bindToLifecycle(
|
||||
this,
|
||||
viewModel.getCameraSelector(),
|
||||
getImageCapture(
|
||||
viewModel.isCropEnabled().getValue(), viewModel.isLowResolutionEnabled().getValue()),
|
||||
getPreview(viewModel.isCropEnabled().getValue()));
|
||||
});
|
||||
binding.retake.setOnClickListener((v) -> {
|
||||
Uri uri = (Uri) binding.photoPreview.getTag();
|
||||
File photoFile = new File(uri.getPath());
|
||||
if (!photoFile.delete()) {
|
||||
Log.w(TAG, "Error deleting temp camera image");
|
||||
}
|
||||
binding.takePhoto.setEnabled(true);
|
||||
binding.photoPreview.setTag(null);
|
||||
showCameraElements();
|
||||
});
|
||||
binding.send.setOnClickListener((v) -> {
|
||||
Uri uri = (Uri) binding.photoPreview.getTag();
|
||||
setResult(RESULT_OK, new Intent().setDataAndType(uri, IMAGE_JPEG));
|
||||
binding.photoPreview.setTag(null);
|
||||
finish();
|
||||
});
|
||||
|
||||
ScaleGestureDetector mDetector =
|
||||
new ScaleGestureDetector(this, new ScaleGestureDetector.SimpleOnScaleGestureListener(){
|
||||
@Override
|
||||
public boolean onScale(ScaleGestureDetector detector){
|
||||
float ratio = camera.getCameraInfo().getZoomState().getValue().getZoomRatio();
|
||||
float delta = detector.getScaleFactor();
|
||||
camera.getCameraControl().setZoomRatio(ratio * delta);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
binding.preview.setOnTouchListener((v, event) -> {
|
||||
v.performClick();
|
||||
mDetector.onTouchEvent(event);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Enable enlarging the image more than default 3x maximumScale.
|
||||
// Medium scale adapted to make double-tap behaviour more consistent.
|
||||
binding.photoPreview.setMaximumScale(MAX_SCALE);
|
||||
binding.photoPreview.setMediumScale(MEDIUM_SCALE);
|
||||
} catch (IllegalArgumentException | ExecutionException | InterruptedException e) {
|
||||
Log.e(TAG, "Error taking picture", e);
|
||||
Snackbar.make(binding.getRoot(), e.getMessage(), Snackbar.LENGTH_LONG).show();
|
||||
finish();
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(this));
|
||||
|
||||
getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback);
|
||||
}
|
||||
|
||||
private void showCameraElements() {
|
||||
binding.send.setVisibility(View.GONE);
|
||||
binding.retake.setVisibility(View.GONE);
|
||||
binding.photoPreview.setVisibility(View.INVISIBLE);
|
||||
|
||||
binding.preview.setVisibility(View.VISIBLE);
|
||||
binding.takePhoto.setVisibility(View.VISIBLE);
|
||||
binding.switchCamera.setVisibility(View.VISIBLE);
|
||||
binding.toggleTorch.setVisibility(View.VISIBLE);
|
||||
binding.toggleCrop.setVisibility(View.VISIBLE);
|
||||
binding.toggleLowres.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
private void showPictureProcessingElements() {
|
||||
binding.preview.setVisibility(View.INVISIBLE);
|
||||
binding.takePhoto.setVisibility(View.GONE);
|
||||
binding.switchCamera.setVisibility(View.GONE);
|
||||
binding.toggleTorch.setVisibility(View.GONE);
|
||||
binding.toggleCrop.setVisibility(View.GONE);
|
||||
binding.toggleLowres.setVisibility(View.GONE);
|
||||
|
||||
binding.send.setVisibility(View.VISIBLE);
|
||||
binding.retake.setVisibility(View.VISIBLE);
|
||||
binding.photoPreview.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
private ImageCapture getImageCapture(Boolean crop, Boolean lowres) {
|
||||
final ImageCapture imageCapture;
|
||||
if (lowres) imageCapture = new ImageCapture.Builder()
|
||||
.setTargetResolution(new Size(crop ? 1080 : 1440, 1920)).build();
|
||||
else imageCapture = new ImageCapture.Builder()
|
||||
.setTargetAspectRatio(crop ? AspectRatio.RATIO_16_9 : AspectRatio.RATIO_4_3).build();
|
||||
|
||||
orientationEventListener = new OrientationEventListener(this) {
|
||||
@Override
|
||||
public void onOrientationChanged(int orientation) {
|
||||
int rotation;
|
||||
|
||||
// Monitors orientation values to determine the target rotation value
|
||||
if (orientation >= 45 && orientation < 135) {
|
||||
rotation = Surface.ROTATION_270;
|
||||
} else if (orientation >= 135 && orientation < 225) {
|
||||
rotation = Surface.ROTATION_180;
|
||||
} else if (orientation >= 225 && orientation < 315) {
|
||||
rotation = Surface.ROTATION_90;
|
||||
} else {
|
||||
rotation = Surface.ROTATION_0;
|
||||
}
|
||||
|
||||
imageCapture.setTargetRotation(rotation);
|
||||
}
|
||||
};
|
||||
orientationEventListener.enable();
|
||||
|
||||
binding.takePhoto.setOnClickListener((v) -> {
|
||||
binding.takePhoto.setEnabled(false);
|
||||
final String photoFileName = dateFormat.format(new Date()) + ".jpg";
|
||||
try {
|
||||
final File photoFile = FileUtils.getTempCacheFile(this, "photos/" + photoFileName);
|
||||
final ImageCapture.OutputFileOptions options =
|
||||
new ImageCapture.OutputFileOptions.Builder(photoFile).build();
|
||||
imageCapture.takePicture(
|
||||
options,
|
||||
ContextCompat.getMainExecutor(this),
|
||||
new ImageCapture.OnImageSavedCallback() {
|
||||
|
||||
@Override
|
||||
public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) {
|
||||
setPreviewImage(photoFile);
|
||||
showPictureProcessingElements();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@NonNull ImageCaptureException e) {
|
||||
Log.e(TAG, "Error", e);
|
||||
|
||||
if (!photoFile.delete()) {
|
||||
Log.w(TAG, "Deleting picture failed");
|
||||
}
|
||||
binding.takePhoto.setEnabled(true);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "error while taking picture", e);
|
||||
Snackbar.make(binding.getRoot(), R.string.take_photo_error_deleting_picture, Snackbar.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
|
||||
return imageCapture;
|
||||
}
|
||||
|
||||
private void setPreviewImage(File photoFile) {
|
||||
final Uri savedUri = Uri.fromFile(photoFile);
|
||||
BitmapFactory.Options options = new BitmapFactory.Options();
|
||||
options.inPreferredConfig = Bitmap.Config.ARGB_8888;
|
||||
DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
|
||||
int doubleScreenWidth = displayMetrics.widthPixels * 2;
|
||||
int doubleScreenHeight = displayMetrics.heightPixels * 2;
|
||||
|
||||
Bitmap bitmap = BitmapShrinker.shrinkBitmap(photoFile.getAbsolutePath(),
|
||||
doubleScreenWidth,
|
||||
doubleScreenHeight);
|
||||
|
||||
binding.photoPreview.setImageBitmap(bitmap);
|
||||
binding.photoPreview.setTag(savedUri);
|
||||
viewModel.disableTorchIfEnabled();
|
||||
}
|
||||
|
||||
public int getImageOrientation(File imageFile) {
|
||||
int rotate = 0;
|
||||
try {
|
||||
ExifInterface exif = new ExifInterface(imageFile.getAbsolutePath());
|
||||
int orientation = exif.getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL);
|
||||
|
||||
switch (orientation) {
|
||||
case ExifInterface.ORIENTATION_ROTATE_270:
|
||||
rotate = 270;
|
||||
break;
|
||||
case ExifInterface.ORIENTATION_ROTATE_180:
|
||||
rotate = 180;
|
||||
break;
|
||||
case ExifInterface.ORIENTATION_ROTATE_90:
|
||||
rotate = 90;
|
||||
break;
|
||||
default:
|
||||
rotate = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
Log.i(TAG, "ImageOrientation - Exif orientation: " + orientation + " - " + "Rotate value: " + rotate);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Error calculation rotation value");
|
||||
}
|
||||
return rotate;
|
||||
}
|
||||
|
||||
@OptIn(markerClass = androidx.camera.camera2.interop.ExperimentalCamera2Interop.class)
|
||||
private Preview getPreview(boolean crop) {
|
||||
Preview.Builder previewBuilder = new Preview.Builder()
|
||||
.setTargetAspectRatio(crop ? AspectRatio.RATIO_16_9 : AspectRatio.RATIO_4_3);
|
||||
new Camera2Interop.Extender<>(previewBuilder)
|
||||
.setCaptureRequestOption(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE,
|
||||
CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_OFF
|
||||
);
|
||||
|
||||
Preview preview = previewBuilder.build();
|
||||
preview.setSurfaceProvider(binding.preview.getSurfaceProvider());
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
if (this.orientationEventListener != null) {
|
||||
this.orientationEventListener.disable();
|
||||
}
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
if (this.orientationEventListener != null) {
|
||||
this.orientationEventListener.enable();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(Bundle savedInstanceState) {
|
||||
if (binding.photoPreview.getTag() != null) {
|
||||
savedInstanceState.putString("Uri", ((Uri) binding.photoPreview.getTag()).getPath());
|
||||
}
|
||||
|
||||
super.onSaveInstanceState(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRestoreInstanceState(Bundle savedInstanceState) {
|
||||
super.onRestoreInstanceState(savedInstanceState);
|
||||
|
||||
String uri = savedInstanceState.getString("Uri", null);
|
||||
|
||||
if (uri != null) {
|
||||
File photoFile = new File(uri);
|
||||
setPreviewImage(photoFile);
|
||||
showPictureProcessingElements();
|
||||
}
|
||||
}
|
||||
|
||||
public static Intent createIntent(@NonNull Context context) {
|
||||
return new Intent(context, TakePhotoActivity.class).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.nextcloud.talk.R
|
||||
import fr.dudie.nominatim.model.Address
|
||||
|
||||
class GeocodingAdapter(private val context: Context, private var dataSource: List<Address>) :
|
||||
RecyclerView.Adapter<GeocodingAdapter.ViewHolder>() {
|
||||
|
||||
interface OnItemClickListener {
|
||||
fun onItemClick(position: Int)
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun updateData(data: List<Address>) {
|
||||
this.dataSource = data
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private var listener: OnItemClickListener? = null
|
||||
fun setOnItemClickListener(listener: OnItemClickListener) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val inflater = LayoutInflater.from(context)
|
||||
val view = inflater.inflate(R.layout.geocoding_item, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val address = dataSource[position]
|
||||
holder.nameView.text = address.displayName
|
||||
|
||||
holder.itemView.setOnClickListener {
|
||||
listener?.onItemClick(position)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = dataSource.size
|
||||
|
||||
fun getItem(position: Int): Any = dataSource[position]
|
||||
|
||||
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val nameView: TextView = itemView.findViewById(R.id.name)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2023 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-FileCopyrightText: 2021-2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import com.nextcloud.talk.call.CallParticipantModel
|
||||
import com.nextcloud.talk.call.RaisedHand
|
||||
import com.nextcloud.talk.models.json.participants.Participant.ActorType
|
||||
import com.nextcloud.talk.utils.ApiUtils.getUrlForAvatar
|
||||
import com.nextcloud.talk.utils.ApiUtils.getUrlForFederatedAvatar
|
||||
import com.nextcloud.talk.utils.ApiUtils.getUrlForGuestAvatar
|
||||
import com.nextcloud.talk.utils.DisplayUtils.isDarkModeOn
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.MediaStream
|
||||
import org.webrtc.PeerConnection.IceConnectionState
|
||||
import org.webrtc.SurfaceViewRenderer
|
||||
|
||||
data class ParticipantUiState(
|
||||
val sessionKey: String,
|
||||
val nick: String,
|
||||
val isConnected: Boolean,
|
||||
val isAudioEnabled: Boolean,
|
||||
val isStreamEnabled: Boolean,
|
||||
val raisedHand: Boolean,
|
||||
val avatarUrl: String?,
|
||||
val mediaStream: MediaStream?
|
||||
)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
class ParticipantDisplayItem(
|
||||
private val context: Context,
|
||||
private val baseUrl: String,
|
||||
private val defaultGuestNick: String,
|
||||
val rootEglBase: EglBase,
|
||||
private val streamType: String,
|
||||
private val roomToken: String,
|
||||
private val callParticipantModel: CallParticipantModel
|
||||
) {
|
||||
private val participantDisplayItemNotifier = ParticipantDisplayItemNotifier()
|
||||
|
||||
private val _uiStateFlow = MutableStateFlow(buildUiState())
|
||||
val uiStateFlow: StateFlow<ParticipantUiState> = _uiStateFlow.asStateFlow()
|
||||
|
||||
private val session: String = callParticipantModel.sessionId
|
||||
|
||||
var actorType: ActorType? = null
|
||||
private set
|
||||
private var actorId: String? = null
|
||||
private var userId: String? = null
|
||||
private var iceConnectionState: IceConnectionState? = null
|
||||
var nick: String? = null
|
||||
get() = (if (TextUtils.isEmpty(userId) && TextUtils.isEmpty(field)) defaultGuestNick else field)
|
||||
|
||||
var urlForAvatar: String? = null
|
||||
private set
|
||||
var mediaStream: MediaStream? = null
|
||||
private set
|
||||
var isStreamEnabled: Boolean = false
|
||||
private set
|
||||
var isAudioEnabled: Boolean = false
|
||||
private set
|
||||
var raisedHand: RaisedHand? = null
|
||||
private set
|
||||
var surfaceViewRenderer: SurfaceViewRenderer? = null
|
||||
|
||||
val sessionKey: String
|
||||
get() = "$session-$streamType"
|
||||
|
||||
interface Observer {
|
||||
fun onChange()
|
||||
}
|
||||
|
||||
private val callParticipantModelObserver: CallParticipantModel.Observer = object : CallParticipantModel.Observer {
|
||||
override fun onChange() {
|
||||
updateFromModel()
|
||||
}
|
||||
|
||||
override fun onReaction(reaction: String) {
|
||||
// unused
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
callParticipantModel.addObserver(callParticipantModelObserver, handler)
|
||||
|
||||
updateFromModel()
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
fun destroy() {
|
||||
callParticipantModel.removeObserver(callParticipantModelObserver)
|
||||
|
||||
surfaceViewRenderer?.let { renderer ->
|
||||
try {
|
||||
mediaStream?.videoTracks?.firstOrNull()?.removeSink(renderer)
|
||||
renderer.clearImage()
|
||||
renderer.release()
|
||||
(renderer.parent as? ViewGroup)?.removeView(renderer)
|
||||
} catch (e: Exception) {
|
||||
Log.w("ParticipantDisplayItem", "Error releasing renderer", e)
|
||||
}
|
||||
}
|
||||
surfaceViewRenderer = null
|
||||
}
|
||||
|
||||
private fun updateFromModel() {
|
||||
actorType = callParticipantModel.actorType
|
||||
actorId = callParticipantModel.actorId
|
||||
userId = callParticipantModel.userId
|
||||
nick = callParticipantModel.nick
|
||||
|
||||
updateUrlForAvatar()
|
||||
|
||||
if (streamType == "screen") {
|
||||
iceConnectionState = callParticipantModel.screenIceConnectionState
|
||||
mediaStream = callParticipantModel.screenMediaStream
|
||||
isAudioEnabled = true
|
||||
isStreamEnabled = true
|
||||
} else {
|
||||
iceConnectionState = callParticipantModel.iceConnectionState
|
||||
mediaStream = callParticipantModel.mediaStream
|
||||
isAudioEnabled = callParticipantModel.isAudioAvailable ?: false
|
||||
isStreamEnabled = callParticipantModel.isVideoAvailable ?: false
|
||||
}
|
||||
|
||||
raisedHand = callParticipantModel.raisedHand
|
||||
|
||||
if (surfaceViewRenderer == null && mediaStream != null) {
|
||||
val renderer = SurfaceViewRenderer(context).apply {
|
||||
init(rootEglBase.eglBaseContext, null)
|
||||
setEnableHardwareScaler(true)
|
||||
setMirror(false)
|
||||
}
|
||||
surfaceViewRenderer = renderer
|
||||
mediaStream?.videoTracks?.firstOrNull()?.addSink(renderer)
|
||||
}
|
||||
|
||||
_uiStateFlow.value = buildUiState()
|
||||
participantDisplayItemNotifier.notifyChange()
|
||||
}
|
||||
|
||||
private fun buildUiState(): ParticipantUiState =
|
||||
ParticipantUiState(
|
||||
sessionKey = sessionKey,
|
||||
nick = nick ?: "Guest",
|
||||
isConnected = isConnected,
|
||||
isAudioEnabled = isAudioEnabled,
|
||||
isStreamEnabled = isStreamEnabled,
|
||||
raisedHand = raisedHand?.state == true,
|
||||
avatarUrl = urlForAvatar,
|
||||
mediaStream = mediaStream
|
||||
)
|
||||
|
||||
private fun updateUrlForAvatar() {
|
||||
if (actorType == ActorType.FEDERATED) {
|
||||
val darkTheme = if (isDarkModeOn(context)) 1 else 0
|
||||
urlForAvatar = getUrlForFederatedAvatar(baseUrl, roomToken, actorId!!, darkTheme, true)
|
||||
} else if (!TextUtils.isEmpty(userId)) {
|
||||
urlForAvatar = getUrlForAvatar(baseUrl, userId, true)
|
||||
} else {
|
||||
urlForAvatar = getUrlForGuestAvatar(baseUrl, nick, true)
|
||||
}
|
||||
}
|
||||
|
||||
val isConnected: Boolean
|
||||
get() = iceConnectionState == IceConnectionState.CONNECTED ||
|
||||
iceConnectionState == IceConnectionState.COMPLETED ||
|
||||
// If there is no connection state that means that no connection is needed,
|
||||
// so it is a special case that is also seen as "connected".
|
||||
iceConnectionState == null
|
||||
|
||||
fun addObserver(observer: Observer?) {
|
||||
participantDisplayItemNotifier.addObserver(observer)
|
||||
}
|
||||
|
||||
fun removeObserver(observer: Observer?) {
|
||||
participantDisplayItemNotifier.removeObserver(observer)
|
||||
}
|
||||
|
||||
override fun toString(): String =
|
||||
"ParticipantSession{" +
|
||||
"userId='" + userId + '\'' +
|
||||
", actorType='" + actorType + '\'' +
|
||||
", actorId='" + actorId + '\'' +
|
||||
", session='" + session + '\'' +
|
||||
", nick='" + nick + '\'' +
|
||||
", urlForAvatar='" + urlForAvatar + '\'' +
|
||||
", mediaStream=" + mediaStream +
|
||||
", streamType='" + streamType + '\'' +
|
||||
", streamEnabled=" + isStreamEnabled +
|
||||
", rootEglBase=" + rootEglBase +
|
||||
", raisedHand=" + raisedHand +
|
||||
'}'
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Shared handler to receive change notifications from the model on the main thread.
|
||||
*/
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Helper class to register and notify ParticipantDisplayItem.Observers.
|
||||
* <p>
|
||||
* This class is only meant for internal use by ParticipantDisplayItem; observers must register themselves against a
|
||||
* ParticipantDisplayItem rather than against a ParticipantDisplayItemNotifier.
|
||||
*/
|
||||
class ParticipantDisplayItemNotifier {
|
||||
|
||||
private final Set<ParticipantDisplayItem.Observer> participantDisplayItemObservers = new LinkedHashSet<>();
|
||||
|
||||
public synchronized void addObserver(ParticipantDisplayItem.Observer observer) {
|
||||
if (observer == null) {
|
||||
throw new IllegalArgumentException("ParticipantDisplayItem.Observer can not be null");
|
||||
}
|
||||
|
||||
participantDisplayItemObservers.add(observer);
|
||||
}
|
||||
|
||||
public synchronized void removeObserver(ParticipantDisplayItem.Observer observer) {
|
||||
participantDisplayItemObservers.remove(observer);
|
||||
}
|
||||
|
||||
public synchronized void notifyChange() {
|
||||
for (ParticipantDisplayItem.Observer observer : new ArrayList<>(participantDisplayItemObservers)) {
|
||||
observer.onChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Tim Krüger <t@timkrueger.me
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters
|
||||
|
||||
import com.nextcloud.talk.models.json.status.predefined.PredefinedStatus
|
||||
|
||||
interface PredefinedStatusClickListener {
|
||||
fun onClick(predefinedStatus: PredefinedStatus)
|
||||
fun revertStatus()
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2020 Tobias Kaminsky <tobias@kaminsky.me>
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.nextcloud.talk.databinding.PredefinedStatusBinding
|
||||
import com.nextcloud.talk.models.json.status.predefined.PredefinedStatus
|
||||
|
||||
class PredefinedStatusListAdapter(
|
||||
private val clickListener: PredefinedStatusClickListener,
|
||||
val context: Context,
|
||||
var isBackupStatusAvailable: Boolean
|
||||
) : RecyclerView.Adapter<PredefinedStatusViewHolder>() {
|
||||
internal var list: List<PredefinedStatus> = emptyList()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PredefinedStatusViewHolder {
|
||||
val itemBinding = PredefinedStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return PredefinedStatusViewHolder(itemBinding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: PredefinedStatusViewHolder, position: Int) {
|
||||
holder.bind(list[position], clickListener, context, isBackupStatusAvailable)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = list.size
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2020 Tobias Kaminsky <tobias@kaminsky.me>
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.databinding.PredefinedStatusBinding
|
||||
import com.nextcloud.talk.models.json.status.predefined.PredefinedStatus
|
||||
import com.nextcloud.talk.utils.DisplayUtils
|
||||
|
||||
private const val ONE_SECOND_IN_MILLIS = 1000
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class PredefinedStatusViewHolder(private val binding: PredefinedStatusBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(
|
||||
status: PredefinedStatus,
|
||||
clickListener: PredefinedStatusClickListener,
|
||||
context: Context,
|
||||
isBackupStatusAvailable: Boolean
|
||||
) {
|
||||
binding.root.setOnClickListener { clickListener.onClick(status) }
|
||||
binding.icon.text = status.icon
|
||||
binding.name.text = status.message
|
||||
|
||||
if (status.clearAt == null) {
|
||||
binding.clearAt.text = context.getString(R.string.dontClear)
|
||||
} else {
|
||||
val clearAt = status.clearAt!!
|
||||
if (clearAt.type.equals("period")) {
|
||||
binding.clearAt.text = DisplayUtils.getRelativeTimestamp(
|
||||
context,
|
||||
System.currentTimeMillis() + clearAt.time.toInt() * ONE_SECOND_IN_MILLIS,
|
||||
true
|
||||
)
|
||||
} else {
|
||||
// end-of
|
||||
if (clearAt.time.equals("day")) {
|
||||
binding.clearAt.text = context.getString(R.string.today)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isBackupStatusAvailable) {
|
||||
binding.resetStatusButton.visibility = if (position == 0) View.VISIBLE else View.GONE
|
||||
if (position == 0) {
|
||||
binding.clearAt.text = context.getString(R.string.previously_set)
|
||||
}
|
||||
binding.resetStatusButton.setOnClickListener {
|
||||
clickListener.revertStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters
|
||||
|
||||
import com.nextcloud.talk.models.json.reactions.ReactionVoter
|
||||
|
||||
data class ReactionItem(val reactionVoter: ReactionVoter, val reaction: String?)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters
|
||||
|
||||
interface ReactionItemClickListener {
|
||||
fun onClick(reactionItem: ReactionItem)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.ReactionItemBinding
|
||||
|
||||
class ReactionsAdapter(private val clickListener: ReactionItemClickListener, private val user: User?) :
|
||||
RecyclerView.Adapter<ReactionsViewHolder>() {
|
||||
internal var list: MutableList<ReactionItem> = ArrayList<ReactionItem>()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReactionsViewHolder {
|
||||
val itemBinding = ReactionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ReactionsViewHolder(itemBinding, user)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ReactionsViewHolder, position: Int) {
|
||||
holder.bind(list[position], clickListener)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = list.size
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters
|
||||
|
||||
import android.text.TextUtils
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.ReactionItemBinding
|
||||
import com.nextcloud.talk.extensions.loadGuestAvatar
|
||||
import com.nextcloud.talk.extensions.loadUserAvatar
|
||||
import com.nextcloud.talk.models.json.reactions.ReactionVoter
|
||||
|
||||
class ReactionsViewHolder(private val binding: ReactionItemBinding, private val user: User?) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(reactionItem: ReactionItem, clickListener: ReactionItemClickListener) {
|
||||
binding.root.setOnClickListener { clickListener.onClick(reactionItem) }
|
||||
binding.reaction.text = reactionItem.reaction
|
||||
binding.name.text = reactionItem.reactionVoter.actorDisplayName
|
||||
|
||||
if (user != null && user.baseUrl?.isNotEmpty() == true) {
|
||||
loadAvatar(reactionItem)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAvatar(reactionItem: ReactionItem) {
|
||||
if (reactionItem.reactionVoter.actorType == ReactionVoter.ReactionActorType.GUESTS) {
|
||||
var displayName = sharedApplication?.resources?.getString(R.string.nc_guest)
|
||||
if (!TextUtils.isEmpty(reactionItem.reactionVoter.actorDisplayName)) {
|
||||
displayName = reactionItem.reactionVoter.actorDisplayName!!
|
||||
}
|
||||
binding.avatar.loadGuestAvatar(user!!.baseUrl!!, displayName!!, false)
|
||||
} else if (reactionItem.reactionVoter.actorType == ReactionVoter.ReactionActorType.USERS) {
|
||||
binding.avatar.loadUserAvatar(
|
||||
user!!,
|
||||
reactionItem.reactionVoter.actorId!!,
|
||||
false,
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <infoi@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.items
|
||||
|
||||
import android.accounts.Account
|
||||
import android.text.TextUtils
|
||||
import android.view.View
|
||||
import androidx.core.net.toUri
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.adapters.items.AdvancedUserItem.UserItemViewHolder
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.AccountItemBinding
|
||||
import com.nextcloud.talk.extensions.loadUserAvatar
|
||||
import com.nextcloud.talk.models.json.participants.Participant
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFilterable
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class AdvancedUserItem(
|
||||
/**
|
||||
* @return the model object
|
||||
*/
|
||||
val model: Participant,
|
||||
@JvmField val user: User?,
|
||||
val account: Account?,
|
||||
private val viewThemeUtils: ViewThemeUtils,
|
||||
private val actionRequiredCount: Int
|
||||
) : AbstractFlexibleItem<UserItemViewHolder>(),
|
||||
IFilterable<String?> {
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
if (other is AdvancedUserItem) {
|
||||
model == other.model
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = model.hashCode()
|
||||
|
||||
override fun getLayoutRes(): Int = R.layout.account_item
|
||||
|
||||
override fun createViewHolder(
|
||||
view: View?,
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?
|
||||
): UserItemViewHolder = UserItemViewHolder(view, adapter)
|
||||
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
holder: UserItemViewHolder,
|
||||
position: Int,
|
||||
payloads: MutableList<Any>
|
||||
) {
|
||||
if (adapter.hasFilter()) {
|
||||
viewThemeUtils.talk.themeAndHighlightText(
|
||||
holder.binding.userName,
|
||||
model.displayName,
|
||||
adapter.getFilter(String::class.java).toString()
|
||||
)
|
||||
} else {
|
||||
holder.binding.userName.text = model.displayName
|
||||
}
|
||||
if (user != null && !TextUtils.isEmpty(user.baseUrl)) {
|
||||
val host = user.baseUrl!!.toUri().host
|
||||
if (!TextUtils.isEmpty(host)) {
|
||||
holder.binding.account.text = user.baseUrl!!.toUri().host
|
||||
} else {
|
||||
holder.binding.account.text = user.baseUrl
|
||||
}
|
||||
}
|
||||
if (user?.baseUrl != null &&
|
||||
(user.baseUrl!!.startsWith("http://") || user.baseUrl!!.startsWith("https://"))
|
||||
) {
|
||||
holder.binding.userIcon.loadUserAvatar(user, model.calculatedActorId!!, true, false)
|
||||
}
|
||||
if (actionRequiredCount > 0) {
|
||||
holder.binding.actionRequired.visibility = View.VISIBLE
|
||||
} else {
|
||||
holder.binding.actionRequired.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
override fun filter(constraint: String?): Boolean =
|
||||
model.displayName != null &&
|
||||
Pattern
|
||||
.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
|
||||
.matcher(model.displayName!!.trim())
|
||||
.find()
|
||||
|
||||
class UserItemViewHolder(view: View?, adapter: FlexibleAdapter<*>?) : FlexibleViewHolder(view, adapter) {
|
||||
var binding: AccountItemBinding
|
||||
|
||||
/**
|
||||
* Default constructor.
|
||||
*/
|
||||
init {
|
||||
binding = AccountItemBinding.bind(view!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <infoi@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.items
|
||||
|
||||
import android.text.TextUtils
|
||||
import android.view.View
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.adapters.items.ContactItem.ContactItemViewHolder
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.RvItemContactBinding
|
||||
import com.nextcloud.talk.extensions.loadUserAvatar
|
||||
import com.nextcloud.talk.models.json.participants.Participant
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFilterable
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.davidea.flexibleadapter.items.ISectionable
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
import java.util.Objects
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class ContactItem(
|
||||
/**
|
||||
* @return the model object
|
||||
*/
|
||||
val model: Participant,
|
||||
private val user: User,
|
||||
private var header: GenericTextHeaderItem?,
|
||||
private val viewThemeUtils: ViewThemeUtils
|
||||
) : AbstractFlexibleItem<ContactItemViewHolder?>(),
|
||||
ISectionable<ContactItemViewHolder?, GenericTextHeaderItem?>,
|
||||
IFilterable<String?> {
|
||||
var isOnline: Boolean = true
|
||||
|
||||
override fun equals(o: Any?): Boolean {
|
||||
if (o is ContactItem) {
|
||||
return model.calculatedActorType == o.model.calculatedActorType &&
|
||||
model.calculatedActorId == o.model.calculatedActorId
|
||||
}
|
||||
return false
|
||||
}
|
||||
override fun hashCode(): Int = model.hashCode()
|
||||
|
||||
override fun filter(constraint: String?): Boolean =
|
||||
model.displayName != null &&
|
||||
(
|
||||
Pattern.compile(constraint!!, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
|
||||
.matcher(model.displayName!!.trim())
|
||||
.find() ||
|
||||
Pattern.compile(constraint!!, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
|
||||
.matcher(model.calculatedActorId!!.trim())
|
||||
.find()
|
||||
)
|
||||
|
||||
override fun getLayoutRes(): Int = R.layout.rv_item_contact
|
||||
|
||||
override fun createViewHolder(
|
||||
view: View?,
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?
|
||||
): ContactItemViewHolder = ContactItemViewHolder(view, adapter)
|
||||
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?,
|
||||
holder: ContactItemViewHolder?,
|
||||
position: Int,
|
||||
payloads: List<Any>?
|
||||
) {
|
||||
if (model.selected) {
|
||||
holder?.binding?.checkedImageView?.let { viewThemeUtils.platform.colorImageView(it) }
|
||||
holder?.binding?.checkedImageView?.visibility = View.VISIBLE
|
||||
} else {
|
||||
holder?.binding?.checkedImageView?.visibility = View.GONE
|
||||
}
|
||||
|
||||
if (!isOnline) {
|
||||
holder?.binding?.nameText?.setTextColor(
|
||||
ResourcesCompat.getColor(
|
||||
holder.binding.nameText.context.resources,
|
||||
R.color.medium_emphasis_text,
|
||||
null
|
||||
)
|
||||
)
|
||||
holder?.binding?.avatarView?.alpha = SEMI_TRANSPARENT
|
||||
} else {
|
||||
holder?.binding?.nameText?.setTextColor(
|
||||
ResourcesCompat.getColor(
|
||||
holder.binding.nameText.context.resources,
|
||||
R.color.high_emphasis_text,
|
||||
null
|
||||
)
|
||||
)
|
||||
holder?.binding?.avatarView?.alpha = FULLY_OPAQUE
|
||||
}
|
||||
|
||||
holder?.binding?.nameText?.text = model.displayName
|
||||
|
||||
if (adapter != null) {
|
||||
if (adapter.hasFilter()) {
|
||||
holder?.binding?.let {
|
||||
viewThemeUtils.talk.themeAndHighlightText(
|
||||
it.nameText,
|
||||
model.displayName,
|
||||
adapter.getFilter(String::class.java).toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(model.displayName) &&
|
||||
(
|
||||
model.type == Participant.ParticipantType.GUEST ||
|
||||
model.type == Participant.ParticipantType.USER_FOLLOWING_LINK
|
||||
)
|
||||
) {
|
||||
holder?.binding?.nameText?.text = sharedApplication!!.getString(R.string.nc_guest)
|
||||
}
|
||||
|
||||
setAvatar(holder)
|
||||
}
|
||||
|
||||
private fun setAvatar(holder: ContactItemViewHolder?) {
|
||||
if (model.calculatedActorType == Participant.ActorType.GROUPS ||
|
||||
model.calculatedActorType == Participant.ActorType.CIRCLES
|
||||
) {
|
||||
setGenericAvatar(holder!!, R.drawable.ic_avatar_group)
|
||||
} else if (model.calculatedActorType == Participant.ActorType.EMAILS) {
|
||||
setGenericAvatar(holder!!, R.drawable.ic_avatar_mail)
|
||||
} else if (model.calculatedActorType == Participant.ActorType.GUESTS ||
|
||||
model.type == Participant.ParticipantType.GUEST ||
|
||||
model.type == Participant.ParticipantType.GUEST_MODERATOR
|
||||
) {
|
||||
var displayName: String?
|
||||
|
||||
displayName = if (!TextUtils.isEmpty(model.displayName)) {
|
||||
model.displayName
|
||||
} else {
|
||||
Objects.requireNonNull(sharedApplication)!!.resources!!.getString(R.string.nc_guest)
|
||||
}
|
||||
|
||||
// absolute fallback to prevent NPE deference
|
||||
if (displayName == null) {
|
||||
displayName = "Guest"
|
||||
}
|
||||
|
||||
holder?.binding?.avatarView?.loadUserAvatar(user, displayName, true, false)
|
||||
} else if (model.calculatedActorType == Participant.ActorType.USERS) {
|
||||
holder?.binding?.avatarView
|
||||
?.loadUserAvatar(
|
||||
user,
|
||||
model.calculatedActorId!!,
|
||||
true,
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setGenericAvatar(holder: ContactItemViewHolder, roundPlaceholderDrawable: Int) {
|
||||
val avatar =
|
||||
viewThemeUtils.talk.themePlaceholderAvatar(
|
||||
holder.binding.avatarView,
|
||||
roundPlaceholderDrawable
|
||||
)
|
||||
|
||||
holder.binding.avatarView.loadUserAvatar(avatar)
|
||||
}
|
||||
|
||||
override fun getHeader(): GenericTextHeaderItem? = header
|
||||
|
||||
override fun setHeader(p0: GenericTextHeaderItem?) {
|
||||
this.header = header
|
||||
}
|
||||
|
||||
class ContactItemViewHolder(view: View?, adapter: FlexibleAdapter<*>?) : FlexibleViewHolder(view, adapter) {
|
||||
var binding: RvItemContactBinding =
|
||||
RvItemContactBinding.bind(view!!)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FULLY_OPAQUE: Float = 1.0f
|
||||
private const val SEMI_TRANSPARENT: Float = 0.38f
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,507 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2022 Tim Krüger <t@timkrueger.me>
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <infoi@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.items
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Typeface
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.TextUtils
|
||||
import android.text.format.DateUtils
|
||||
import android.text.style.ImageSpan
|
||||
import android.view.View
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import coil.dispose
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.adapters.items.ConversationItem.ConversationItemViewHolder
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage.MessageType
|
||||
import com.nextcloud.talk.data.database.mappers.asModel
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.RvItemConversationWithLastMessageBinding
|
||||
import com.nextcloud.talk.extensions.loadConversationAvatar
|
||||
import com.nextcloud.talk.extensions.loadNoteToSelfAvatar
|
||||
import com.nextcloud.talk.extensions.loadSystemAvatar
|
||||
import com.nextcloud.talk.extensions.loadUserAvatar
|
||||
import com.nextcloud.talk.models.domain.ConversationModel
|
||||
import com.nextcloud.talk.models.json.conversations.ConversationEnums
|
||||
import com.nextcloud.talk.ui.StatusDrawable
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
|
||||
import com.nextcloud.talk.utils.DisplayUtils
|
||||
import com.nextcloud.talk.utils.SpreedFeatures
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFilterable
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.davidea.flexibleadapter.items.ISectionable
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class ConversationItem(
|
||||
val model: ConversationModel,
|
||||
private val user: User,
|
||||
private val context: Context,
|
||||
private val viewThemeUtils: ViewThemeUtils
|
||||
) : AbstractFlexibleItem<ConversationItemViewHolder>(),
|
||||
ISectionable<ConversationItemViewHolder, GenericTextHeaderItem?>,
|
||||
IFilterable<String?> {
|
||||
private var header: GenericTextHeaderItem? = null
|
||||
private val chatMessage = model.lastMessage?.asModel()
|
||||
var mHolder: ConversationItemViewHolder? = null
|
||||
|
||||
constructor(
|
||||
conversation: ConversationModel,
|
||||
user: User,
|
||||
activityContext: Context,
|
||||
genericTextHeaderItem: GenericTextHeaderItem?,
|
||||
viewThemeUtils: ViewThemeUtils
|
||||
) : this(conversation, user, activityContext, viewThemeUtils) {
|
||||
header = genericTextHeaderItem
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other is ConversationItem) {
|
||||
return model == other.model
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = model.hashCode()
|
||||
result *= 31
|
||||
return result
|
||||
}
|
||||
|
||||
override fun getLayoutRes(): Int = R.layout.rv_item_conversation_with_last_message
|
||||
|
||||
override fun getItemViewType(): Int = VIEW_TYPE
|
||||
|
||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<*>?>?): ConversationItemViewHolder =
|
||||
ConversationItemViewHolder(view, adapter)
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<*>?>,
|
||||
holder: ConversationItemViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
mHolder = holder
|
||||
val appContext = sharedApplication!!.applicationContext
|
||||
holder.binding.dialogName.setTextColor(
|
||||
ResourcesCompat.getColor(
|
||||
context.resources,
|
||||
R.color.conversation_item_header,
|
||||
null
|
||||
)
|
||||
)
|
||||
if (adapter.hasFilter()) {
|
||||
viewThemeUtils.platform.highlightText(
|
||||
holder.binding.dialogName,
|
||||
model.displayName!!,
|
||||
adapter.getFilter(String::class.java).toString()
|
||||
)
|
||||
} else {
|
||||
holder.binding.dialogName.text = model.displayName
|
||||
}
|
||||
if (model.unreadMessages > 0) {
|
||||
showUnreadMessages(holder)
|
||||
} else {
|
||||
holder.binding.dialogName.setTypeface(null, Typeface.NORMAL)
|
||||
holder.binding.dialogDate.setTypeface(null, Typeface.NORMAL)
|
||||
holder.binding.dialogLastMessage.setTypeface(null, Typeface.NORMAL)
|
||||
holder.binding.dialogUnreadBubble.visibility = View.GONE
|
||||
}
|
||||
if (model.favorite) {
|
||||
holder.binding.favoriteConversationImageView.visibility = View.VISIBLE
|
||||
} else {
|
||||
holder.binding.favoriteConversationImageView.visibility = View.GONE
|
||||
}
|
||||
if (ConversationEnums.ConversationType.ROOM_PUBLIC_CALL == model.type) {
|
||||
holder.binding.publicCallBadge.setImageResource(R.drawable.ic_avatar_link)
|
||||
holder.binding.publicCallBadge.visibility = View.VISIBLE
|
||||
} else if (model.remoteServer?.isNotEmpty() == true) {
|
||||
holder.binding.publicCallBadge.setImageResource(R.drawable.ic_avatar_federation)
|
||||
holder.binding.publicCallBadge.visibility = View.VISIBLE
|
||||
} else {
|
||||
holder.binding.publicCallBadge.visibility = View.GONE
|
||||
}
|
||||
if (ConversationEnums.ConversationType.ROOM_SYSTEM !== model.type) {
|
||||
val size = DisplayUtils.convertDpToPixel(STATUS_SIZE_IN_DP, appContext)
|
||||
holder.binding.userStatusImage.visibility = View.VISIBLE
|
||||
holder.binding.userStatusImage.setImageDrawable(
|
||||
StatusDrawable(
|
||||
model.status,
|
||||
model.statusIcon,
|
||||
size,
|
||||
context.resources.getColor(R.color.bg_default, null),
|
||||
appContext
|
||||
)
|
||||
)
|
||||
} else {
|
||||
holder.binding.userStatusImage.visibility = View.GONE
|
||||
}
|
||||
|
||||
val dialogNameParams = holder.binding.dialogName.layoutParams as RelativeLayout.LayoutParams
|
||||
val unreadBubbleParams = holder.binding.dialogUnreadBubble.layoutParams as RelativeLayout.LayoutParams
|
||||
val relativeLayoutParams = holder.binding.relativeLayout.layoutParams as RelativeLayout.LayoutParams
|
||||
|
||||
if (model.hasSensitive == true) {
|
||||
dialogNameParams.addRule(RelativeLayout.CENTER_VERTICAL)
|
||||
relativeLayoutParams.addRule(RelativeLayout.ALIGN_TOP, R.id.dialogAvatarFrameLayout)
|
||||
dialogNameParams.marginEnd =
|
||||
context.resources.getDimensionPixelSize(R.dimen.standard_double_padding)
|
||||
unreadBubbleParams.topMargin =
|
||||
context.resources.getDimensionPixelSize(R.dimen.double_margin_between_elements)
|
||||
unreadBubbleParams.addRule(RelativeLayout.CENTER_VERTICAL)
|
||||
} else {
|
||||
dialogNameParams.removeRule(RelativeLayout.CENTER_VERTICAL)
|
||||
relativeLayoutParams.removeRule(RelativeLayout.ALIGN_TOP)
|
||||
dialogNameParams.marginEnd = 0
|
||||
unreadBubbleParams.topMargin = 0
|
||||
unreadBubbleParams.removeRule(RelativeLayout.CENTER_VERTICAL)
|
||||
}
|
||||
holder.binding.relativeLayout.layoutParams = relativeLayoutParams
|
||||
holder.binding.dialogUnreadBubble.layoutParams = unreadBubbleParams
|
||||
holder.binding.dialogName.layoutParams = dialogNameParams
|
||||
|
||||
setLastMessage(holder, appContext)
|
||||
showAvatar(holder)
|
||||
}
|
||||
|
||||
private fun showAvatar(holder: ConversationItemViewHolder) {
|
||||
holder.binding.dialogAvatar.dispose()
|
||||
holder.binding.dialogAvatar.visibility = View.VISIBLE
|
||||
|
||||
var shouldLoadAvatar = shouldLoadAvatar(holder)
|
||||
if (ConversationEnums.ConversationType.ROOM_SYSTEM == model.type) {
|
||||
holder.binding.dialogAvatar.loadSystemAvatar()
|
||||
shouldLoadAvatar = false
|
||||
}
|
||||
if (shouldLoadAvatar) {
|
||||
when (model.type) {
|
||||
ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> {
|
||||
if (!TextUtils.isEmpty(model.name)) {
|
||||
holder.binding.dialogAvatar.loadUserAvatar(
|
||||
user,
|
||||
model.name!!,
|
||||
true,
|
||||
false
|
||||
)
|
||||
} else {
|
||||
holder.binding.dialogAvatar.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
ConversationEnums.ConversationType.ROOM_GROUP_CALL,
|
||||
ConversationEnums.ConversationType.FORMER_ONE_TO_ONE,
|
||||
ConversationEnums.ConversationType.ROOM_PUBLIC_CALL ->
|
||||
holder.binding.dialogAvatar.loadConversationAvatar(user, model, false, viewThemeUtils)
|
||||
|
||||
ConversationEnums.ConversationType.NOTE_TO_SELF ->
|
||||
holder.binding.dialogAvatar.loadNoteToSelfAvatar()
|
||||
|
||||
else -> holder.binding.dialogAvatar.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldLoadAvatar(holder: ConversationItemViewHolder): Boolean =
|
||||
when (model.objectType) {
|
||||
ConversationEnums.ObjectType.SHARE_PASSWORD -> {
|
||||
holder.binding.dialogAvatar.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
context,
|
||||
R.drawable.ic_circular_lock
|
||||
)
|
||||
)
|
||||
false
|
||||
}
|
||||
|
||||
ConversationEnums.ObjectType.FILE -> {
|
||||
holder.binding.dialogAvatar.loadUserAvatar(
|
||||
viewThemeUtils.talk.themePlaceholderAvatar(
|
||||
holder.binding.dialogAvatar,
|
||||
R.drawable.ic_avatar_document
|
||||
)
|
||||
)
|
||||
false
|
||||
}
|
||||
|
||||
else -> true
|
||||
}
|
||||
|
||||
private fun setLastMessage(holder: ConversationItemViewHolder, appContext: Context) {
|
||||
if (chatMessage != null) {
|
||||
holder.binding.dialogDate.visibility = View.VISIBLE
|
||||
holder.binding.dialogDate.text = DateUtils.getRelativeTimeSpanString(
|
||||
model.lastActivity * MILLIES,
|
||||
System.currentTimeMillis(),
|
||||
0,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE
|
||||
)
|
||||
if (!TextUtils.isEmpty(chatMessage?.systemMessage) ||
|
||||
ConversationEnums.ConversationType.ROOM_SYSTEM === model.type
|
||||
) {
|
||||
holder.binding.dialogLastMessage.text = chatMessage.text
|
||||
} else {
|
||||
chatMessage?.activeUser = user
|
||||
|
||||
val text =
|
||||
if (
|
||||
chatMessage?.getCalculateMessageType() == MessageType.REGULAR_TEXT_MESSAGE
|
||||
) {
|
||||
calculateRegularLastMessageText(appContext)
|
||||
} else {
|
||||
lastMessageDisplayText
|
||||
}
|
||||
holder.binding.dialogLastMessage.text = text
|
||||
}
|
||||
} else {
|
||||
holder.binding.dialogDate.visibility = View.GONE
|
||||
holder.binding.dialogLastMessage.text = ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateRegularLastMessageText(appContext: Context): CharSequence =
|
||||
if (chatMessage?.actorId == user.userId) {
|
||||
String.format(
|
||||
appContext.getString(R.string.nc_formatted_message_you),
|
||||
lastMessageDisplayText
|
||||
)
|
||||
} else if (model.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
|
||||
lastMessageDisplayText
|
||||
} else {
|
||||
val actorName = chatMessage?.actorDisplayName
|
||||
val authorDisplayName = if (!actorName.isNullOrBlank()) {
|
||||
actorName
|
||||
} else if ("guests" == chatMessage?.actorType || "emails" == chatMessage?.actorType) {
|
||||
appContext.getString(R.string.nc_guest)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
String.format(
|
||||
appContext.getString(R.string.nc_formatted_message),
|
||||
authorDisplayName,
|
||||
lastMessageDisplayText
|
||||
)
|
||||
}
|
||||
|
||||
private fun showUnreadMessages(holder: ConversationItemViewHolder) {
|
||||
holder.binding.dialogName.setTypeface(holder.binding.dialogName.typeface, Typeface.BOLD)
|
||||
holder.binding.dialogLastMessage.setTypeface(holder.binding.dialogLastMessage.typeface, Typeface.BOLD)
|
||||
holder.binding.dialogUnreadBubble.visibility = View.VISIBLE
|
||||
if (model.unreadMessages < UNREAD_MESSAGES_TRESHOLD) {
|
||||
holder.binding.dialogUnreadBubble.text = model.unreadMessages.toLong().toString()
|
||||
} else {
|
||||
holder.binding.dialogUnreadBubble.setText(R.string.tooManyUnreadMessages)
|
||||
}
|
||||
val lightBubbleFillColor = ColorStateList.valueOf(
|
||||
ContextCompat.getColor(
|
||||
context,
|
||||
R.color.conversation_unread_bubble
|
||||
)
|
||||
)
|
||||
val lightBubbleTextColor = ContextCompat.getColor(
|
||||
context,
|
||||
R.color.conversation_unread_bubble_text
|
||||
)
|
||||
if (model.type === ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
|
||||
viewThemeUtils.material.colorChipBackground(holder.binding.dialogUnreadBubble)
|
||||
} else if (model.unreadMention) {
|
||||
if (hasSpreedFeatureCapability(user.capabilities?.spreedCapability!!, SpreedFeatures.DIRECT_MENTION_FLAG)) {
|
||||
if (model.unreadMentionDirect!!) {
|
||||
viewThemeUtils.material.colorChipBackground(holder.binding.dialogUnreadBubble)
|
||||
} else {
|
||||
viewThemeUtils.material.colorChipOutlined(
|
||||
holder.binding.dialogUnreadBubble,
|
||||
UNREAD_BUBBLE_STROKE_WIDTH
|
||||
)
|
||||
}
|
||||
} else {
|
||||
viewThemeUtils.material.colorChipBackground(holder.binding.dialogUnreadBubble)
|
||||
}
|
||||
} else {
|
||||
holder.binding.dialogUnreadBubble.chipBackgroundColor = lightBubbleFillColor
|
||||
holder.binding.dialogUnreadBubble.setTextColor(lightBubbleTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
override fun filter(constraint: String?): Boolean =
|
||||
model.displayName != null &&
|
||||
Pattern
|
||||
.compile(constraint!!, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
|
||||
.matcher(model.displayName.trim())
|
||||
.find()
|
||||
|
||||
override fun getHeader(): GenericTextHeaderItem? = header
|
||||
|
||||
override fun setHeader(header: GenericTextHeaderItem?) {
|
||||
this.header = header
|
||||
}
|
||||
|
||||
private val lastMessageDisplayText: CharSequence
|
||||
get() {
|
||||
if (chatMessage?.getCalculateMessageType() == MessageType.REGULAR_TEXT_MESSAGE ||
|
||||
chatMessage?.getCalculateMessageType() == MessageType.SYSTEM_MESSAGE ||
|
||||
chatMessage?.getCalculateMessageType() == MessageType.SINGLE_LINK_MESSAGE
|
||||
) {
|
||||
return chatMessage.text
|
||||
} else {
|
||||
if (MessageType.SINGLE_LINK_GIPHY_MESSAGE == chatMessage?.getCalculateMessageType() ||
|
||||
MessageType.SINGLE_LINK_TENOR_MESSAGE == chatMessage?.getCalculateMessageType() ||
|
||||
MessageType.SINGLE_LINK_GIF_MESSAGE == chatMessage?.getCalculateMessageType()
|
||||
) {
|
||||
return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
|
||||
sharedApplication!!.getString(R.string.nc_sent_a_gif_you)
|
||||
} else {
|
||||
String.format(
|
||||
sharedApplication!!.resources.getString(R.string.nc_sent_a_gif),
|
||||
chatMessage?.getNullsafeActorDisplayName()
|
||||
)
|
||||
}
|
||||
} else if (MessageType.SINGLE_NC_GEOLOCATION_MESSAGE == chatMessage?.getCalculateMessageType()) {
|
||||
var locationName = chatMessage.messageParameters?.get("object")?.get("name") ?: ""
|
||||
val author = authorName(chatMessage)
|
||||
val lastMessage =
|
||||
setLastNameForAttachmentMessage(author, R.drawable.baseline_location_pin_24, locationName)
|
||||
return lastMessage
|
||||
} else if (MessageType.VOICE_MESSAGE == chatMessage?.getCalculateMessageType()) {
|
||||
var voiceMessageName = chatMessage.messageParameters?.get("file")?.get("name") ?: ""
|
||||
val author = authorName(chatMessage)
|
||||
val lastMessage = setLastNameForAttachmentMessage(
|
||||
author,
|
||||
R.drawable.baseline_mic_24,
|
||||
voiceMessageName
|
||||
)
|
||||
return lastMessage
|
||||
} else if (MessageType.SINGLE_LINK_AUDIO_MESSAGE == chatMessage?.getCalculateMessageType()) {
|
||||
return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
|
||||
sharedApplication!!.getString(R.string.nc_sent_an_audio_you)
|
||||
} else {
|
||||
String.format(
|
||||
sharedApplication!!.resources.getString(R.string.nc_sent_an_audio),
|
||||
chatMessage?.getNullsafeActorDisplayName()
|
||||
)
|
||||
}
|
||||
} else if (MessageType.SINGLE_LINK_VIDEO_MESSAGE == chatMessage?.getCalculateMessageType()) {
|
||||
return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
|
||||
sharedApplication!!.getString(R.string.nc_sent_a_video_you)
|
||||
} else {
|
||||
String.format(
|
||||
sharedApplication!!.resources.getString(R.string.nc_sent_a_video),
|
||||
chatMessage?.getNullsafeActorDisplayName()
|
||||
)
|
||||
}
|
||||
} else if (MessageType.SINGLE_LINK_IMAGE_MESSAGE == chatMessage?.getCalculateMessageType()) {
|
||||
return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
|
||||
sharedApplication!!.getString(R.string.nc_sent_an_image_you)
|
||||
} else {
|
||||
String.format(
|
||||
sharedApplication!!.resources.getString(R.string.nc_sent_an_image),
|
||||
chatMessage?.getNullsafeActorDisplayName()
|
||||
)
|
||||
}
|
||||
} else if (MessageType.POLL_MESSAGE == chatMessage?.getCalculateMessageType()) {
|
||||
var pollMessageTitle = chatMessage.messageParameters?.get("object")?.get("name") ?: ""
|
||||
val author = authorName(chatMessage)
|
||||
val lastMessage = setLastNameForAttachmentMessage(
|
||||
author,
|
||||
R.drawable.baseline_bar_chart_24,
|
||||
pollMessageTitle
|
||||
)
|
||||
return lastMessage
|
||||
} else if (MessageType.SINGLE_NC_ATTACHMENT_MESSAGE == chatMessage?.getCalculateMessageType()) {
|
||||
var attachmentName = chatMessage.text
|
||||
if (attachmentName == "{file}") {
|
||||
attachmentName = chatMessage.messageParameters?.get("file")?.get("name") ?: ""
|
||||
}
|
||||
val author = authorName(chatMessage)
|
||||
|
||||
val drawable = chatMessage.messageParameters?.get("file")?.get("mimetype")?.let {
|
||||
when {
|
||||
it.contains("image") -> R.drawable.baseline_image_24
|
||||
it.contains("video") -> R.drawable.baseline_video_24
|
||||
it.contains("application") -> R.drawable.baseline_insert_drive_file_24
|
||||
it.contains("audio") -> R.drawable.baseline_audiotrack_24
|
||||
it.contains("text/vcard") -> R.drawable.baseline_contacts_24
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
val lastMessage = setLastNameForAttachmentMessage(author, drawable, attachmentName!!)
|
||||
return lastMessage
|
||||
} else if (MessageType.DECK_CARD == chatMessage?.getCalculateMessageType()) {
|
||||
var deckTitle = chatMessage.messageParameters?.get("object")?.get("name") ?: ""
|
||||
val author = authorName(chatMessage)
|
||||
val lastMessage = setLastNameForAttachmentMessage(author, R.drawable.baseline_article_24, deckTitle)
|
||||
return lastMessage
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
fun authorName(chatMessage: ChatMessage): String {
|
||||
val name = if (chatMessage.actorId == chatMessage.activeUser!!.userId) {
|
||||
sharedApplication!!.resources.getString(R.string.nc_current_user)
|
||||
} else {
|
||||
chatMessage.getNullsafeActorDisplayName()?.let { "$it:" } ?: ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
fun setLastNameForAttachmentMessage(actor: String, icon: Int?, attachmentName: String): SpannableStringBuilder {
|
||||
val builder = SpannableStringBuilder()
|
||||
builder.append(actor)
|
||||
|
||||
val drawable = icon?.let { it -> ContextCompat.getDrawable(context, it) }
|
||||
if (drawable != null) {
|
||||
viewThemeUtils.platform.colorDrawable(
|
||||
drawable,
|
||||
context.resources.getColor(R.color.low_emphasis_text, null)
|
||||
)
|
||||
val desiredWidth = (drawable.intrinsicWidth * IMAGE_SCALE_FACTOR).toInt()
|
||||
val desiredHeight = (drawable.intrinsicHeight * IMAGE_SCALE_FACTOR).toInt()
|
||||
drawable.setBounds(0, 0, desiredWidth, desiredHeight)
|
||||
|
||||
val imageSpan = ImageSpan(drawable, ImageSpan.ALIGN_BOTTOM)
|
||||
val startImage = builder.length
|
||||
builder.append(" ")
|
||||
builder.setSpan(imageSpan, startImage, startImage + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
} else {
|
||||
builder.append(" ")
|
||||
}
|
||||
builder.append(attachmentName)
|
||||
return builder
|
||||
}
|
||||
|
||||
class ConversationItemViewHolder(view: View?, adapter: FlexibleAdapter<*>?) : FlexibleViewHolder(view, adapter) {
|
||||
var binding: RvItemConversationWithLastMessageBinding
|
||||
|
||||
init {
|
||||
binding = RvItemConversationWithLastMessageBinding.bind(view!!)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VIEW_TYPE = FlexibleItemViewType.CONVERSATION_ITEM
|
||||
private const val MILLIES = 1000L
|
||||
private const val STATUS_SIZE_IN_DP = 9f
|
||||
private const val UNREAD_BUBBLE_STROKE_WIDTH = 6.0f
|
||||
private const val UNREAD_MESSAGES_TRESHOLD = 1000
|
||||
private const val IMAGE_SCALE_FACTOR = 0.7f
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2023 Andy Scherzinger <infoi@andy-scherzinger.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.items
|
||||
|
||||
object FlexibleItemViewType {
|
||||
const val CONVERSATION_ITEM: Int = 1120391230
|
||||
const val LOAD_MORE_RESULTS_ITEM: Int = 1120391231
|
||||
const val MESSAGE_RESULT_ITEM: Int = 1120391232
|
||||
const val MESSAGES_TEXT_HEADER_ITEM: Int = 1120391233
|
||||
const val POLL_RESULT_HEADER_ITEM: Int = 1120391234
|
||||
const val POLL_RESULT_VOTER_ITEM: Int = 1120391235
|
||||
const val POLL_RESULT_VOTERS_OVERVIEW_ITEM: Int = 1120391236
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <infoi@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.items
|
||||
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.databinding.RvItemTitleHeaderBinding
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
import java.util.Objects
|
||||
|
||||
open class GenericTextHeaderItem(title: String, viewThemeUtils: ViewThemeUtils) :
|
||||
AbstractHeaderItem<GenericTextHeaderItem.HeaderViewHolder>() {
|
||||
val model: String
|
||||
private val viewThemeUtils: ViewThemeUtils
|
||||
|
||||
init {
|
||||
isHidden = false
|
||||
isSelectable = false
|
||||
this.model = title
|
||||
this.viewThemeUtils = viewThemeUtils
|
||||
}
|
||||
|
||||
override fun equals(o: Any?): Boolean {
|
||||
if (o is GenericTextHeaderItem) {
|
||||
return model == o.model
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = Objects.hash(model)
|
||||
|
||||
override fun getLayoutRes(): Int = R.layout.rv_item_title_header
|
||||
|
||||
override fun createViewHolder(
|
||||
view: View?,
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?
|
||||
): HeaderViewHolder = HeaderViewHolder(view, adapter)
|
||||
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<*>?>?,
|
||||
holder: HeaderViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
if (payloads.size > 0) {
|
||||
Log.d(TAG, "We have payloads, so ignoring!")
|
||||
} else {
|
||||
holder.binding.titleTextView.text = model
|
||||
viewThemeUtils.platform.colorPrimaryTextViewElement(holder.binding.titleTextView)
|
||||
}
|
||||
}
|
||||
|
||||
class HeaderViewHolder(view: View?, adapter: FlexibleAdapter<*>?) : FlexibleViewHolder(view, adapter, true) {
|
||||
var binding: RvItemTitleHeaderBinding =
|
||||
RvItemTitleHeaderBinding.bind(view!!)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GenericTextHeaderItem"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.items
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.databinding.RvItemLoadMoreBinding
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFilterable
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
|
||||
object LoadMoreResultsItem :
|
||||
AbstractFlexibleItem<LoadMoreResultsItem.ViewHolder>(),
|
||||
IFilterable<String> {
|
||||
|
||||
// layout is used as view type for uniqueness
|
||||
const val VIEW_TYPE = FlexibleItemViewType.LOAD_MORE_RESULTS_ITEM
|
||||
|
||||
class ViewHolder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) {
|
||||
var binding: RvItemLoadMoreBinding = RvItemLoadMoreBinding.bind(view)
|
||||
}
|
||||
|
||||
override fun getLayoutRes(): Int = R.layout.rv_item_load_more
|
||||
|
||||
override fun createViewHolder(
|
||||
view: View,
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>
|
||||
): ViewHolder = ViewHolder(view, adapter)
|
||||
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
holder: ViewHolder,
|
||||
position: Int,
|
||||
payloads: MutableList<Any>?
|
||||
) {
|
||||
// nothing, it's immutable
|
||||
}
|
||||
|
||||
override fun filter(constraint: String?): Boolean = true
|
||||
|
||||
override fun getItemViewType(): Int = VIEW_TYPE
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is LoadMoreResultsItem
|
||||
|
||||
override fun hashCode(): Int = 0
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2021-2022 Andy Scherzinger <infoi@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.items
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import com.nextcloud.talk.PhoneUtils.isPhoneNumber
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.adapters.items.ParticipantItem.ParticipantItemViewHolder
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.extensions.loadDefaultAvatar
|
||||
import com.nextcloud.talk.extensions.loadFederatedUserAvatar
|
||||
import com.nextcloud.talk.extensions.loadGuestAvatar
|
||||
import com.nextcloud.talk.extensions.loadUserAvatar
|
||||
import com.nextcloud.talk.models.json.mention.Mention
|
||||
import com.nextcloud.talk.models.json.status.StatusType
|
||||
import com.nextcloud.talk.ui.StatusDrawable
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.DisplayUtils
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFilterable
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import java.util.Objects
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class MentionAutocompleteItem(
|
||||
mention: Mention,
|
||||
private val currentUser: User,
|
||||
private val context: Context,
|
||||
@JvmField val roomToken: String,
|
||||
private val viewThemeUtils: ViewThemeUtils
|
||||
) : AbstractFlexibleItem<ParticipantItemViewHolder>(),
|
||||
IFilterable<String?> {
|
||||
@JvmField
|
||||
var source: String?
|
||||
|
||||
@JvmField
|
||||
val mentionId: String?
|
||||
|
||||
@JvmField
|
||||
val objectId: String?
|
||||
|
||||
@JvmField
|
||||
val displayName: String?
|
||||
private val status: String?
|
||||
private val statusIcon: String?
|
||||
private val statusMessage: String?
|
||||
|
||||
init {
|
||||
mentionId = mention.mentionId
|
||||
objectId = mention.id
|
||||
|
||||
displayName = if (!mention.label.isNullOrBlank()) {
|
||||
mention.label
|
||||
} else if ("guests" == mention.source || "emails" == mention.source) {
|
||||
context.resources.getString(R.string.nc_guest)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
source = mention.source
|
||||
status = mention.status
|
||||
statusIcon = mention.statusIcon
|
||||
statusMessage = mention.statusMessage
|
||||
}
|
||||
|
||||
override fun equals(o: Any?): Boolean =
|
||||
if (o is MentionAutocompleteItem) {
|
||||
objectId == o.objectId && displayName == o.displayName
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = Objects.hash(objectId, displayName)
|
||||
|
||||
override fun getLayoutRes(): Int = R.layout.rv_item_conversation_info_participant
|
||||
|
||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<*>?>?): ParticipantItemViewHolder =
|
||||
ParticipantItemViewHolder(view, adapter)
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<*>?>,
|
||||
holder: ParticipantItemViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
holder.binding.nameText.setTextColor(
|
||||
ResourcesCompat.getColor(
|
||||
context.resources,
|
||||
R.color.conversation_item_header,
|
||||
null
|
||||
)
|
||||
)
|
||||
if (adapter.hasFilter()) {
|
||||
viewThemeUtils.talk.themeAndHighlightText(
|
||||
holder.binding.nameText,
|
||||
displayName,
|
||||
adapter.getFilter(String::class.java).toString()
|
||||
)
|
||||
viewThemeUtils.talk.themeAndHighlightText(
|
||||
holder.binding.secondaryText,
|
||||
"@$objectId",
|
||||
adapter.getFilter(String::class.java).toString()
|
||||
)
|
||||
} else {
|
||||
holder.binding.nameText.text = displayName
|
||||
}
|
||||
setAvatar(holder, objectId)
|
||||
drawStatus(holder)
|
||||
}
|
||||
|
||||
private fun setAvatar(holder: ParticipantItemViewHolder, objectId: String?) {
|
||||
when (source) {
|
||||
SOURCE_CALLS -> {
|
||||
run {
|
||||
if (isPhoneNumber(displayName)) {
|
||||
holder.binding.avatarView.loadUserAvatar(
|
||||
viewThemeUtils.talk.themePlaceholderAvatar(
|
||||
holder.binding.avatarView,
|
||||
R.drawable.ic_phone_small
|
||||
)
|
||||
)
|
||||
} else {
|
||||
holder.binding.avatarView.loadUserAvatar(
|
||||
viewThemeUtils.talk.themePlaceholderAvatar(
|
||||
holder.binding.avatarView,
|
||||
R.drawable.ic_avatar_group_small
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SOURCE_GROUPS -> {
|
||||
holder.binding.avatarView.loadUserAvatar(
|
||||
viewThemeUtils.talk.themePlaceholderAvatar(
|
||||
holder.binding.avatarView,
|
||||
R.drawable
|
||||
.ic_avatar_group_small
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
SOURCE_FEDERATION -> {
|
||||
val darkTheme = if (DisplayUtils.isDarkModeOn(context)) 1 else 0
|
||||
holder.binding.avatarView.loadFederatedUserAvatar(
|
||||
currentUser,
|
||||
currentUser.baseUrl!!,
|
||||
roomToken,
|
||||
objectId!!,
|
||||
darkTheme,
|
||||
requestBigSize = true,
|
||||
ignoreCache = false
|
||||
)
|
||||
}
|
||||
|
||||
SOURCE_GUESTS, SOURCE_EMAILS -> {
|
||||
if (displayName.equals(context.resources.getString(R.string.nc_guest))) {
|
||||
holder.binding.avatarView.loadDefaultAvatar(viewThemeUtils)
|
||||
} else {
|
||||
holder.binding.avatarView.loadGuestAvatar(currentUser, displayName!!, false)
|
||||
}
|
||||
}
|
||||
|
||||
SOURCE_TEAMS -> {
|
||||
holder.binding.avatarView.loadUserAvatar(
|
||||
viewThemeUtils.talk.themePlaceholderAvatar(
|
||||
holder.binding.avatarView,
|
||||
R.drawable
|
||||
.ic_avatar_team_small
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
holder.binding.avatarView.loadUserAvatar(
|
||||
currentUser,
|
||||
objectId!!,
|
||||
requestBigSize = true,
|
||||
ignoreCache = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawStatus(holder: ParticipantItemViewHolder) {
|
||||
val size = DisplayUtils.convertDpToPixel(STATUS_SIZE_IN_DP, context)
|
||||
holder.binding.userStatusImage.setImageDrawable(
|
||||
StatusDrawable(
|
||||
status,
|
||||
NO_ICON,
|
||||
size,
|
||||
context.resources.getColor(R.color.bg_default),
|
||||
context
|
||||
)
|
||||
)
|
||||
if (statusMessage != null) {
|
||||
holder.binding.conversationInfoStatusMessage.text = statusMessage
|
||||
alignUsernameVertical(holder, 0f)
|
||||
} else {
|
||||
holder.binding.conversationInfoStatusMessage.text = ""
|
||||
alignUsernameVertical(holder, NO_USER_STATUS_DP_FROM_TOP)
|
||||
}
|
||||
if (!statusIcon.isNullOrEmpty()) {
|
||||
holder.binding.participantStatusEmoji.setText(statusIcon)
|
||||
} else {
|
||||
holder.binding.participantStatusEmoji.visibility = View.GONE
|
||||
}
|
||||
if (status != null && status == StatusType.DND.string) {
|
||||
if (statusMessage.isNullOrEmpty()) {
|
||||
holder.binding.conversationInfoStatusMessage.setText(R.string.dnd)
|
||||
}
|
||||
} else if (status != null && status == StatusType.BUSY.string) {
|
||||
if (statusMessage.isNullOrEmpty()) {
|
||||
holder.binding.conversationInfoStatusMessage.setText(R.string.busy)
|
||||
}
|
||||
} else if (status != null && status == StatusType.AWAY.string) {
|
||||
if (statusMessage.isNullOrEmpty()) {
|
||||
holder.binding.conversationInfoStatusMessage.setText(R.string.away)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun alignUsernameVertical(holder: ParticipantItemViewHolder, densityPixelsFromTop: Float) {
|
||||
val layoutParams = holder.binding.nameText.layoutParams as ConstraintLayout.LayoutParams
|
||||
layoutParams.topMargin = DisplayUtils.convertDpToPixel(densityPixelsFromTop, context).toInt()
|
||||
holder.binding.nameText.setLayoutParams(layoutParams)
|
||||
}
|
||||
|
||||
override fun filter(constraint: String?): Boolean =
|
||||
objectId != null &&
|
||||
Pattern
|
||||
.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
|
||||
.matcher(objectId)
|
||||
.find() ||
|
||||
displayName != null &&
|
||||
Pattern
|
||||
.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
|
||||
.matcher(displayName)
|
||||
.find()
|
||||
|
||||
companion object {
|
||||
private const val STATUS_SIZE_IN_DP = 9f
|
||||
private const val NO_ICON = ""
|
||||
private const val NO_USER_STATUS_DP_FROM_TOP: Float = 10f
|
||||
const val SOURCE_CALLS = "calls"
|
||||
const val SOURCE_GUESTS = "guests"
|
||||
const val SOURCE_GROUPS = "groups"
|
||||
const val SOURCE_EMAILS = "emails"
|
||||
const val SOURCE_TEAMS = "teams"
|
||||
const val SOURCE_FEDERATION = "federated_users"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
|
||||
* SPDX-FileCopyrightText: 2022 Tim Krüger <t@timkrueger.me>
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <infoi@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.items
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.RvItemSearchMessageBinding
|
||||
import com.nextcloud.talk.extensions.loadThumbnail
|
||||
import com.nextcloud.talk.models.domain.SearchMessageEntry
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFilterable
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.davidea.flexibleadapter.items.ISectionable
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
|
||||
data class MessageResultItem(
|
||||
private val context: Context,
|
||||
private val currentUser: User,
|
||||
val messageEntry: SearchMessageEntry,
|
||||
var showHeader: Boolean = false,
|
||||
private val viewThemeUtils: ViewThemeUtils
|
||||
) : AbstractFlexibleItem<MessageResultItem.ViewHolder>(),
|
||||
IFilterable<String>,
|
||||
ISectionable<MessageResultItem.ViewHolder, GenericTextHeaderItem> {
|
||||
|
||||
class ViewHolder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) {
|
||||
var binding: RvItemSearchMessageBinding
|
||||
|
||||
init {
|
||||
binding = RvItemSearchMessageBinding.bind(view)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLayoutRes(): Int = R.layout.rv_item_search_message
|
||||
|
||||
override fun createViewHolder(
|
||||
view: View,
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>
|
||||
): ViewHolder = ViewHolder(view, adapter)
|
||||
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
holder: ViewHolder,
|
||||
position: Int,
|
||||
payloads: MutableList<Any>?
|
||||
) {
|
||||
holder.binding.conversationTitle.text = messageEntry.title
|
||||
bindMessageExcerpt(holder)
|
||||
messageEntry.thumbnailURL?.let { holder.binding.thumbnail.loadThumbnail(it, currentUser) }
|
||||
}
|
||||
|
||||
private fun bindMessageExcerpt(holder: ViewHolder) {
|
||||
viewThemeUtils.platform.highlightText(
|
||||
holder.binding.messageExcerpt,
|
||||
messageEntry.messageExcerpt,
|
||||
messageEntry.searchTerm
|
||||
)
|
||||
}
|
||||
|
||||
override fun filter(constraint: String?): Boolean = true
|
||||
|
||||
override fun getItemViewType(): Int = VIEW_TYPE
|
||||
|
||||
companion object {
|
||||
const val VIEW_TYPE = FlexibleItemViewType.MESSAGE_RESULT_ITEM
|
||||
}
|
||||
|
||||
override fun getHeader(): GenericTextHeaderItem =
|
||||
MessagesTextHeaderItem(context, viewThemeUtils)
|
||||
.apply {
|
||||
isHidden = showHeader // FlexibleAdapter needs this hack for some reason
|
||||
}
|
||||
|
||||
override fun setHeader(header: GenericTextHeaderItem?) {
|
||||
// nothing, header is always the same
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.items
|
||||
|
||||
import android.content.Context
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
|
||||
class MessagesTextHeaderItem(context: Context, viewThemeUtils: ViewThemeUtils) :
|
||||
GenericTextHeaderItem(context.getString(R.string.messages), viewThemeUtils) {
|
||||
companion object {
|
||||
const val VIEW_TYPE = FlexibleItemViewType.MESSAGES_TEXT_HEADER_ITEM
|
||||
}
|
||||
|
||||
override fun getItemViewType(): Int = VIEW_TYPE
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <infoi@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.items;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import com.nextcloud.talk.R;
|
||||
import com.nextcloud.talk.databinding.RvItemNotificationSoundBinding;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter;
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem;
|
||||
import eu.davidea.flexibleadapter.items.IFlexible;
|
||||
import eu.davidea.viewholders.FlexibleViewHolder;
|
||||
|
||||
public class NotificationSoundItem extends AbstractFlexibleItem<NotificationSoundItem.NotificationSoundItemViewHolder> {
|
||||
|
||||
private final String notificationSoundName;
|
||||
private final String notificationSoundUri;
|
||||
|
||||
public NotificationSoundItem(String notificationSoundName, String notificationSoundUri) {
|
||||
this.notificationSoundName = notificationSoundName;
|
||||
this.notificationSoundUri = notificationSoundUri;
|
||||
}
|
||||
|
||||
public String getNotificationSoundUri() {
|
||||
return notificationSoundUri;
|
||||
}
|
||||
|
||||
public String getNotificationSoundName() {
|
||||
return notificationSoundName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
NotificationSoundItem that = (NotificationSoundItem) o;
|
||||
|
||||
if (!Objects.equals(notificationSoundName, that.notificationSoundName)) {
|
||||
return false;
|
||||
}
|
||||
return Objects.equals(notificationSoundUri, that.notificationSoundUri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = notificationSoundName != null ? notificationSoundName.hashCode() : 0;
|
||||
return 31 * result + (notificationSoundUri != null ? notificationSoundUri.hashCode() : 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLayoutRes() {
|
||||
return R.layout.rv_item_notification_sound;
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationSoundItemViewHolder createViewHolder(View view, FlexibleAdapter<IFlexible> adapter) {
|
||||
return new NotificationSoundItemViewHolder(view, adapter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bindViewHolder(FlexibleAdapter<IFlexible> adapter,
|
||||
NotificationSoundItemViewHolder holder,
|
||||
int position,
|
||||
List<Object> payloads) {
|
||||
holder.binding.notificationNameTextView.setText(notificationSoundName);
|
||||
holder.binding.notificationNameTextView.setChecked(adapter.isSelected(position));
|
||||
}
|
||||
|
||||
static class NotificationSoundItemViewHolder extends FlexibleViewHolder {
|
||||
|
||||
RvItemNotificationSoundBinding binding;
|
||||
|
||||
/**
|
||||
* Default constructor.
|
||||
*/
|
||||
NotificationSoundItemViewHolder(View view, FlexibleAdapter adapter) {
|
||||
super(view, adapter);
|
||||
binding = RvItemNotificationSoundBinding.bind(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <infoi@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.items
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.adapters.items.ParticipantItem.ParticipantItemViewHolder
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.RvItemConversationInfoParticipantBinding
|
||||
import com.nextcloud.talk.extensions.loadDefaultAvatar
|
||||
import com.nextcloud.talk.extensions.loadDefaultGroupCallAvatar
|
||||
import com.nextcloud.talk.extensions.loadFederatedUserAvatar
|
||||
import com.nextcloud.talk.extensions.loadFirstLetterAvatar
|
||||
import com.nextcloud.talk.extensions.loadPhoneAvatar
|
||||
import com.nextcloud.talk.extensions.loadTeamAvatar
|
||||
import com.nextcloud.talk.extensions.loadUserAvatar
|
||||
import com.nextcloud.talk.models.domain.ConversationModel
|
||||
import com.nextcloud.talk.models.json.participants.Participant
|
||||
import com.nextcloud.talk.models.json.participants.Participant.InCallFlags
|
||||
import com.nextcloud.talk.models.json.status.StatusType
|
||||
import com.nextcloud.talk.ui.StatusDrawable
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.ConversationUtils
|
||||
import com.nextcloud.talk.utils.DisplayUtils
|
||||
import com.nextcloud.talk.utils.DisplayUtils.convertDpToPixel
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFilterable
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class ParticipantItem(
|
||||
private val context: Context,
|
||||
val model: Participant,
|
||||
private val user: User,
|
||||
private val viewThemeUtils: ViewThemeUtils,
|
||||
private val conversation: ConversationModel
|
||||
) : AbstractFlexibleItem<ParticipantItemViewHolder>(),
|
||||
IFilterable<String?> {
|
||||
var isOnline = true
|
||||
override fun equals(o: Any?): Boolean =
|
||||
if (o is ParticipantItem) {
|
||||
model.calculatedActorType == o.model.calculatedActorType &&
|
||||
model.calculatedActorId == o.model.calculatedActorId
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = model.hashCode()
|
||||
|
||||
override fun getLayoutRes(): Int = R.layout.rv_item_conversation_info_participant
|
||||
|
||||
override fun createViewHolder(
|
||||
view: View?,
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?
|
||||
): ParticipantItemViewHolder = ParticipantItemViewHolder(view, adapter)
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?,
|
||||
holder: ParticipantItemViewHolder?,
|
||||
position: Int,
|
||||
payloads: List<*>?
|
||||
) {
|
||||
drawStatus(holder!!)
|
||||
setOnlineStateColor(holder)
|
||||
holder.binding.nameText.text = model.displayName
|
||||
|
||||
if (model.type == Participant.ParticipantType.GUEST && model.displayName.isNullOrBlank()) {
|
||||
holder.binding.nameText.text = sharedApplication!!.getString(R.string.nc_guest)
|
||||
}
|
||||
|
||||
if (adapter!!.hasFilter()) {
|
||||
viewThemeUtils.talk.themeAndHighlightText(
|
||||
holder.binding.nameText,
|
||||
model.displayName,
|
||||
adapter.getFilter(
|
||||
String::class.java
|
||||
).toString()
|
||||
)
|
||||
}
|
||||
loadAvatars(holder)
|
||||
showCallIcons(holder)
|
||||
setParticipantInfo(holder)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private fun setParticipantInfo(holder: ParticipantItemViewHolder) {
|
||||
if (TextUtils.isEmpty(model.displayName) &&
|
||||
(
|
||||
model.type == Participant.ParticipantType.GUEST ||
|
||||
model.type == Participant.ParticipantType.USER_FOLLOWING_LINK
|
||||
)
|
||||
) {
|
||||
holder.binding.nameText.text = sharedApplication!!.getString(R.string.nc_guest)
|
||||
}
|
||||
|
||||
var userType = ""
|
||||
when (model.type) {
|
||||
Participant.ParticipantType.OWNER,
|
||||
Participant.ParticipantType.MODERATOR,
|
||||
Participant.ParticipantType.GUEST_MODERATOR -> {
|
||||
userType = sharedApplication!!.getString(R.string.nc_moderator)
|
||||
}
|
||||
|
||||
Participant.ParticipantType.USER -> {
|
||||
userType = sharedApplication!!.getString(R.string.nc_user)
|
||||
if (model.calculatedActorType == Participant.ActorType.GROUPS) {
|
||||
userType = sharedApplication!!.getString(R.string.nc_group)
|
||||
}
|
||||
if (model.calculatedActorType == Participant.ActorType.CIRCLES) {
|
||||
userType = sharedApplication!!.getString(R.string.nc_team)
|
||||
}
|
||||
}
|
||||
|
||||
Participant.ParticipantType.GUEST -> {
|
||||
userType = sharedApplication!!.getString(R.string.nc_guest)
|
||||
if (model.calculatedActorType == Participant.ActorType.EMAILS) {
|
||||
userType = sharedApplication!!.getString(R.string.nc_guest)
|
||||
}
|
||||
|
||||
if (model.invitedActorId?.isNotEmpty() == true &&
|
||||
ConversationUtils.isParticipantOwnerOrModerator(conversation)
|
||||
) {
|
||||
holder.binding.conversationInfoStatusMessage.text = model.invitedActorId
|
||||
alignUsernameVertical(holder, 0f)
|
||||
}
|
||||
}
|
||||
|
||||
Participant.ParticipantType.USER_FOLLOWING_LINK -> {
|
||||
userType = sharedApplication!!.getString(R.string.nc_following_link)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
if (userType != sharedApplication!!.getString(R.string.nc_user)) {
|
||||
holder.binding.secondaryText.text = "($userType)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun setOnlineStateColor(holder: ParticipantItemViewHolder) {
|
||||
if (!isOnline) {
|
||||
holder.binding.nameText.setTextColor(
|
||||
ResourcesCompat.getColor(
|
||||
holder.binding.nameText.context.resources,
|
||||
R.color.medium_emphasis_text,
|
||||
null
|
||||
)
|
||||
)
|
||||
holder.binding.avatarView.setAlpha(NOT_ONLINE_ALPHA)
|
||||
} else {
|
||||
holder.binding.nameText.setTextColor(
|
||||
ResourcesCompat.getColor(
|
||||
holder.binding.nameText.context.resources,
|
||||
R.color.high_emphasis_text,
|
||||
null
|
||||
)
|
||||
)
|
||||
holder.binding.avatarView.setAlpha(1.0f)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StringFormatInvalid")
|
||||
private fun showCallIcons(holder: ParticipantItemViewHolder) {
|
||||
val resources = sharedApplication!!.resources
|
||||
val inCallFlag = model.inCall
|
||||
if (inCallFlag and InCallFlags.WITH_PHONE.toLong() > 0) {
|
||||
holder.binding.videoCallIcon.setImageResource(R.drawable.ic_call_grey_600_24dp)
|
||||
holder.binding.videoCallIcon.setVisibility(View.VISIBLE)
|
||||
holder.binding.videoCallIcon.setContentDescription(
|
||||
resources.getString(R.string.nc_call_state_with_phone, model.displayName)
|
||||
)
|
||||
} else if (inCallFlag and InCallFlags.WITH_VIDEO.toLong() > 0) {
|
||||
holder.binding.videoCallIcon.setImageResource(R.drawable.ic_videocam_grey_600_24dp)
|
||||
holder.binding.videoCallIcon.setVisibility(View.VISIBLE)
|
||||
holder.binding.videoCallIcon.setContentDescription(
|
||||
resources.getString(R.string.nc_call_state_with_video, model.displayName)
|
||||
)
|
||||
} else if (inCallFlag > InCallFlags.DISCONNECTED) {
|
||||
holder.binding.videoCallIcon.setImageResource(R.drawable.ic_mic_grey_600_24dp)
|
||||
holder.binding.videoCallIcon.setVisibility(View.VISIBLE)
|
||||
holder.binding.videoCallIcon.setContentDescription(
|
||||
resources.getString(R.string.nc_call_state_in_call, model.displayName)
|
||||
)
|
||||
} else {
|
||||
holder.binding.videoCallIcon.setVisibility(View.GONE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAvatars(holder: ParticipantItemViewHolder) {
|
||||
when (model.calculatedActorType) {
|
||||
Participant.ActorType.GROUPS -> {
|
||||
holder.binding.avatarView.loadDefaultGroupCallAvatar(viewThemeUtils)
|
||||
}
|
||||
|
||||
Participant.ActorType.CIRCLES -> {
|
||||
holder.binding.avatarView.loadTeamAvatar(viewThemeUtils)
|
||||
}
|
||||
|
||||
Participant.ActorType.USERS -> {
|
||||
holder.binding.avatarView.loadUserAvatar(user, model.calculatedActorId!!, true, false)
|
||||
}
|
||||
|
||||
Participant.ActorType.GUESTS, Participant.ActorType.EMAILS -> {
|
||||
val actorName = model.displayName
|
||||
if (!actorName.isNullOrBlank()) {
|
||||
holder.binding.avatarView.loadFirstLetterAvatar(actorName)
|
||||
} else {
|
||||
holder.binding.avatarView.loadDefaultAvatar(viewThemeUtils)
|
||||
}
|
||||
}
|
||||
|
||||
Participant.ActorType.FEDERATED -> {
|
||||
val darkTheme = if (DisplayUtils.isDarkModeOn(context)) 1 else 0
|
||||
holder.binding.avatarView.loadFederatedUserAvatar(
|
||||
user,
|
||||
user.baseUrl!!,
|
||||
conversation.token,
|
||||
model.actorId!!,
|
||||
darkTheme,
|
||||
true,
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
Participant.ActorType.PHONES -> {
|
||||
holder.binding.avatarView.loadPhoneAvatar(viewThemeUtils)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Avatar not shown because of unknown ActorType " + model.calculatedActorType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun drawStatus(holder: ParticipantItemViewHolder) {
|
||||
val size = convertDpToPixel(STATUS_SIZE_IN_DP, context)
|
||||
holder.binding.userStatusImage.setImageDrawable(
|
||||
StatusDrawable(
|
||||
model.status,
|
||||
NO_ICON,
|
||||
size,
|
||||
context.resources.getColor(R.color.bg_default),
|
||||
context
|
||||
)
|
||||
)
|
||||
if (model.statusMessage != null) {
|
||||
holder.binding.conversationInfoStatusMessage.text = model.statusMessage
|
||||
alignUsernameVertical(holder, 0f)
|
||||
} else {
|
||||
holder.binding.conversationInfoStatusMessage.text = ""
|
||||
alignUsernameVertical(holder, 10f)
|
||||
}
|
||||
if (model.statusIcon != null && model.statusIcon!!.isNotEmpty()) {
|
||||
holder.binding.participantStatusEmoji.setText(model.statusIcon)
|
||||
} else {
|
||||
holder.binding.participantStatusEmoji.visibility = View.GONE
|
||||
}
|
||||
if (model.status != null && model.status == StatusType.DND.string) {
|
||||
if (model.statusMessage == null || model.statusMessage!!.isEmpty()) {
|
||||
holder.binding.conversationInfoStatusMessage.setText(R.string.dnd)
|
||||
}
|
||||
} else if (model.status != null && model.status == StatusType.BUSY.string) {
|
||||
if (model.statusMessage == null || model.statusMessage!!.isEmpty()) {
|
||||
holder.binding.conversationInfoStatusMessage.setText(R.string.busy)
|
||||
}
|
||||
} else if (model.status != null && model.status == StatusType.AWAY.string) {
|
||||
if (model.statusMessage == null || model.statusMessage!!.isEmpty()) {
|
||||
holder.binding.conversationInfoStatusMessage.setText(R.string.away)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun alignUsernameVertical(holder: ParticipantItemViewHolder, densityPixelsFromTop: Float) {
|
||||
val layoutParams = holder.binding.nameText.layoutParams as ConstraintLayout.LayoutParams
|
||||
layoutParams.topMargin = convertDpToPixel(densityPixelsFromTop, context).toInt()
|
||||
holder.binding.nameText.setLayoutParams(layoutParams)
|
||||
}
|
||||
|
||||
override fun filter(constraint: String?): Boolean =
|
||||
model.displayName != null &&
|
||||
(
|
||||
Pattern.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
|
||||
.matcher(model.displayName!!.trim()).find() ||
|
||||
Pattern.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
|
||||
.matcher(model.calculatedActorId!!.trim()).find()
|
||||
)
|
||||
|
||||
class ParticipantItemViewHolder internal constructor(view: View?, adapter: FlexibleAdapter<*>?) :
|
||||
FlexibleViewHolder(view, adapter) {
|
||||
var binding: RvItemConversationInfoParticipantBinding
|
||||
|
||||
init {
|
||||
binding = RvItemConversationInfoParticipantBinding.bind(view!!)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = ParticipantItem::class.simpleName
|
||||
private const val STATUS_SIZE_IN_DP = 9f
|
||||
private const val NO_ICON = ""
|
||||
private const val NOT_ONLINE_ALPHA = 0.38f
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.items
|
||||
|
||||
import android.view.View
|
||||
import com.nextcloud.talk.R
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
|
||||
class SpacerItem(private val height: Int) : AbstractFlexibleItem<SpacerItem.ViewHolder>() {
|
||||
|
||||
override fun getLayoutRes(): Int = R.layout.item_spacer
|
||||
|
||||
override fun createViewHolder(view: View?, adapter: FlexibleAdapter<IFlexible<*>?>?): ViewHolder =
|
||||
ViewHolder(view!!, adapter!!)
|
||||
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<*>?>?,
|
||||
holder: ViewHolder,
|
||||
position: Int,
|
||||
payloads: MutableList<Any>?
|
||||
) {
|
||||
holder.itemView.layoutParams.height = height
|
||||
}
|
||||
|
||||
override fun equals(other: Any?) = other is SpacerItem
|
||||
|
||||
override fun hashCode(): Int = 0
|
||||
|
||||
class ViewHolder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter)
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingDeckCardMessageBinding
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingLinkPreviewMessageBinding
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingLocationMessageBinding
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingPollMessageBinding
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingTextMessageBinding
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingVoiceMessageBinding
|
||||
import com.nextcloud.talk.models.domain.ConversationModel
|
||||
import com.nextcloud.talk.models.json.conversations.ConversationEnums.ConversationType
|
||||
|
||||
interface AdjustableMessageHolderInterface {
|
||||
|
||||
val binding: ViewBinding
|
||||
|
||||
fun adjustIfNoteToSelf(currentConversation: ConversationModel?) {
|
||||
if (currentConversation?.type == ConversationType.NOTE_TO_SELF) {
|
||||
when (this.binding.javaClass) {
|
||||
ItemCustomOutcomingTextMessageBinding::class.java ->
|
||||
(this.binding as ItemCustomOutcomingTextMessageBinding).bubble
|
||||
ItemCustomOutcomingDeckCardMessageBinding::class.java ->
|
||||
(this.binding as ItemCustomOutcomingDeckCardMessageBinding).bubble
|
||||
ItemCustomOutcomingLinkPreviewMessageBinding::class.java ->
|
||||
(this.binding as ItemCustomOutcomingLinkPreviewMessageBinding).bubble
|
||||
ItemCustomOutcomingPollMessageBinding::class.java ->
|
||||
(this.binding as ItemCustomOutcomingPollMessageBinding).bubble
|
||||
ItemCustomOutcomingVoiceMessageBinding::class.java ->
|
||||
(this.binding as ItemCustomOutcomingVoiceMessageBinding).bubble
|
||||
ItemCustomOutcomingLocationMessageBinding::class.java ->
|
||||
(this.binding as ItemCustomOutcomingLocationMessageBinding).bubble
|
||||
else -> null
|
||||
}?.let {
|
||||
RelativeLayout.LayoutParams(binding.root.layoutParams).apply {
|
||||
marginStart = 0
|
||||
marginEnd = 0
|
||||
}.run {
|
||||
it.layoutParams = this
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2023 Julius Linus <julius.linus@nextcloud.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
interface CallStartedMessageInterface {
|
||||
fun joinAudioCall()
|
||||
fun joinVideoCall()
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
|
||||
interface CommonMessageInterface {
|
||||
fun onLongClickReactions(chatMessage: ChatMessage)
|
||||
fun onClickReaction(chatMessage: ChatMessage, emoji: String)
|
||||
fun onOpenMessageActionsDialog(chatMessage: ChatMessage)
|
||||
fun openThread(chatMessage: ChatMessage)
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Sowjanya Kota <sowjanya.kch@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import autodagger.AutoInjector
|
||||
import coil.load
|
||||
import com.nextcloud.android.common.ui.theme.utils.ColorRole
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ItemCustomIncomingDeckCardMessageBinding
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.ChatMessageUtils
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import com.stfalcon.chatkit.messages.MessageHolders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class IncomingDeckCardViewHolder(incomingView: View, payload: Any) :
|
||||
MessageHolders
|
||||
.IncomingTextMessageViewHolder<ChatMessage>(incomingView, payload) {
|
||||
|
||||
private val binding: ItemCustomIncomingDeckCardMessageBinding =
|
||||
ItemCustomIncomingDeckCardMessageBinding.bind(itemView)
|
||||
|
||||
@Inject
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var appPreferences: AppPreferences
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var dateUtils: DateUtils
|
||||
|
||||
@Inject
|
||||
lateinit var ncApi: NcApi
|
||||
|
||||
lateinit var message: ChatMessage
|
||||
|
||||
lateinit var commonMessageInterface: CommonMessageInterface
|
||||
|
||||
var stackName: String? = null
|
||||
var cardName: String? = null
|
||||
var boardName: String? = null
|
||||
var cardLink: String? = null
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
this.message = message
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
|
||||
setAvatarAndAuthorOnMessageItem(message)
|
||||
showDeckCard(message)
|
||||
|
||||
colorizeMessageBubble(message)
|
||||
|
||||
binding.cardView.findViewById<ImageView>(R.id.deckCardImage)?.let {
|
||||
viewThemeUtils.platform.colorImageView(it, ColorRole.SECONDARY)
|
||||
}
|
||||
|
||||
itemView.isSelected = false
|
||||
|
||||
// parent message handling
|
||||
setParentMessageDataOnMessageItem(message)
|
||||
|
||||
binding.cardView.setOnLongClickListener { l: View? ->
|
||||
commonMessageInterface.onOpenMessageActionsDialog(message)
|
||||
true
|
||||
}
|
||||
|
||||
binding.cardView.setOnClickListener {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, cardLink!!.toUri())
|
||||
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(browserIntent)
|
||||
}
|
||||
|
||||
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
::longClickOnReaction,
|
||||
binding.reactions,
|
||||
binding.messageTime.context,
|
||||
false,
|
||||
viewThemeUtils
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("StringFormatInvalid")
|
||||
private fun showDeckCard(message: ChatMessage) {
|
||||
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
|
||||
for (key in message.messageParameters!!.keys) {
|
||||
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
|
||||
if (individualHashMap["type"] == "deck-card") {
|
||||
cardName = individualHashMap["name"]
|
||||
stackName = individualHashMap["stackname"]
|
||||
boardName = individualHashMap["boardname"]
|
||||
cardLink = individualHashMap["link"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cardName?.isNotEmpty() == true) {
|
||||
val cardDescription = String.format(
|
||||
context.resources.getString(R.string.deck_card_description),
|
||||
stackName,
|
||||
boardName
|
||||
)
|
||||
binding.cardName.visibility = View.VISIBLE
|
||||
binding.cardDescription.visibility = View.VISIBLE
|
||||
binding.cardName.text = cardName
|
||||
binding.cardDescription.text = cardDescription
|
||||
}
|
||||
}
|
||||
|
||||
private fun longClickOnReaction(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.onLongClickReactions(chatMessage)
|
||||
}
|
||||
|
||||
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
|
||||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
|
||||
val actorName = message.actorDisplayName
|
||||
if (!actorName.isNullOrBlank()) {
|
||||
binding.messageAuthor.visibility = View.VISIBLE
|
||||
binding.messageAuthor.text = actorName
|
||||
binding.messageUserAvatar.setOnClickListener {
|
||||
(payload as? MessagePayload)?.profileBottomSheet?.showFor(message, itemView.context)
|
||||
}
|
||||
} else {
|
||||
binding.messageAuthor.setText(R.string.nc_nick_guest)
|
||||
}
|
||||
|
||||
if (!message.isGrouped && !message.isOneToOneConversation && !message.isFormerOneToOneConversation) {
|
||||
ChatMessageUtils().setAvatarOnMessage(binding.messageUserAvatar, message, viewThemeUtils)
|
||||
} else {
|
||||
if (message.isOneToOneConversation || message.isFormerOneToOneConversation) {
|
||||
binding.messageUserAvatar.visibility = View.GONE
|
||||
} else {
|
||||
binding.messageUserAvatar.visibility = View.INVISIBLE
|
||||
}
|
||||
binding.messageAuthor.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorizeMessageBubble(message: ChatMessage) {
|
||||
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val urlForChatting = ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
chatActivity.conversationUser?.baseUrl,
|
||||
chatActivity.roomToken
|
||||
)
|
||||
|
||||
val parentChatMessage = withContext(Dispatchers.IO) {
|
||||
chatActivity.chatViewModel.getMessageById(
|
||||
urlForChatting,
|
||||
chatActivity.currentConversation!!,
|
||||
message.parentMessageId!!
|
||||
).first()
|
||||
}
|
||||
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 = messageUtils
|
||||
.enrichChatReplyMessageText(
|
||||
binding.messageQuote.quotedMessage.context,
|
||||
parentChatMessage,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedMessageAuthor
|
||||
.setTextColor(ContextCompat.getColor(context, R.color.textColorMaxContrast))
|
||||
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
|
||||
this.commonMessageInterface = commonMessageInterface
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = IncomingDeckCardViewHolder::class.java.simpleName
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import autodagger.AutoInjector
|
||||
import coil.load
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ItemCustomIncomingLinkPreviewMessageBinding
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.ChatMessageUtils
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import com.stfalcon.chatkit.messages.MessageHolders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class IncomingLinkPreviewMessageViewHolder(incomingView: View, payload: Any) :
|
||||
MessageHolders.IncomingTextMessageViewHolder<ChatMessage>(incomingView, payload) {
|
||||
|
||||
private val binding: ItemCustomIncomingLinkPreviewMessageBinding =
|
||||
ItemCustomIncomingLinkPreviewMessageBinding.bind(itemView)
|
||||
|
||||
@Inject
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var appPreferences: AppPreferences
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var dateUtils: DateUtils
|
||||
|
||||
@Inject
|
||||
lateinit var ncApi: NcApi
|
||||
|
||||
lateinit var message: ChatMessage
|
||||
|
||||
lateinit var commonMessageInterface: CommonMessageInterface
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
this.message = message
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
|
||||
var processedMessageText = messageUtils.enrichChatMessageText(
|
||||
binding.messageText.context,
|
||||
message,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
processedMessageText = messageUtils.processMessageParameters(
|
||||
binding.messageText.context,
|
||||
viewThemeUtils,
|
||||
processedMessageText!!,
|
||||
message,
|
||||
itemView
|
||||
)
|
||||
|
||||
binding.messageText.text = processedMessageText
|
||||
|
||||
setAvatarAndAuthorOnMessageItem(message)
|
||||
|
||||
colorizeMessageBubble(message)
|
||||
|
||||
itemView.isSelected = false
|
||||
|
||||
// parent message handling
|
||||
setParentMessageDataOnMessageItem(message)
|
||||
|
||||
LinkPreview().showLink(
|
||||
message,
|
||||
ncApi,
|
||||
binding.referenceInclude,
|
||||
itemView.context
|
||||
)
|
||||
binding.referenceInclude.referenceWrapper.setOnLongClickListener { l: View? ->
|
||||
commonMessageInterface.onOpenMessageActionsDialog(message)
|
||||
true
|
||||
}
|
||||
|
||||
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
|
||||
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val showThreadButton = chatActivity.conversationThreadId == null && message.isThread
|
||||
if (showThreadButton) {
|
||||
binding.reactions.threadButton.visibility = View.VISIBLE
|
||||
binding.reactions.threadButton.setContent {
|
||||
ThreadButtonComposable(
|
||||
onButtonClick = { openThread(message) }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
binding.reactions.threadButton.visibility = View.GONE
|
||||
}
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
::longClickOnReaction,
|
||||
binding.reactions,
|
||||
binding.messageTime.context,
|
||||
false,
|
||||
viewThemeUtils
|
||||
)
|
||||
}
|
||||
|
||||
private fun longClickOnReaction(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.onLongClickReactions(chatMessage)
|
||||
}
|
||||
|
||||
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
|
||||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
|
||||
val actorName = message.actorDisplayName
|
||||
if (!actorName.isNullOrBlank()) {
|
||||
binding.messageAuthor.visibility = View.VISIBLE
|
||||
binding.messageAuthor.text = actorName
|
||||
binding.messageUserAvatar.setOnClickListener {
|
||||
(payload as? MessagePayload)?.profileBottomSheet?.showFor(message, itemView.context)
|
||||
}
|
||||
} else {
|
||||
binding.messageAuthor.setText(R.string.nc_nick_guest)
|
||||
}
|
||||
|
||||
if (!message.isGrouped && !message.isOneToOneConversation && !message.isFormerOneToOneConversation) {
|
||||
ChatMessageUtils().setAvatarOnMessage(binding.messageUserAvatar, message, viewThemeUtils)
|
||||
} else {
|
||||
if (message.isOneToOneConversation || message.isFormerOneToOneConversation) {
|
||||
binding.messageUserAvatar.visibility = View.GONE
|
||||
} else {
|
||||
binding.messageUserAvatar.visibility = View.INVISIBLE
|
||||
}
|
||||
binding.messageAuthor.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorizeMessageBubble(message: ChatMessage) {
|
||||
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val urlForChatting = ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
chatActivity.conversationUser?.baseUrl,
|
||||
chatActivity.roomToken
|
||||
)
|
||||
|
||||
val parentChatMessage = withContext(Dispatchers.IO) {
|
||||
chatActivity.chatViewModel.getMessageById(
|
||||
urlForChatting,
|
||||
chatActivity.currentConversation!!,
|
||||
message.parentMessageId!!
|
||||
).first()
|
||||
}
|
||||
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 = messageUtils
|
||||
.enrichChatReplyMessageText(
|
||||
binding.messageQuote.quotedMessage.context,
|
||||
parentChatMessage,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedMessageAuthor
|
||||
.setTextColor(ContextCompat.getColor(context, R.color.textColorMaxContrast))
|
||||
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
|
||||
this.commonMessageInterface = commonMessageInterface
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = IncomingLinkPreviewMessageViewHolder::class.java.simpleName
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2021 Tim Krüger <t@timkrueger.me>
|
||||
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.core.net.toUri
|
||||
import autodagger.AutoInjector
|
||||
import coil.load
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ItemCustomIncomingLocationMessageBinding
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.ChatMessageUtils
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.UriUtils
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import com.stfalcon.chatkit.messages.MessageHolders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.URLEncoder
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class IncomingLocationMessageViewHolder(incomingView: View, payload: Any) :
|
||||
MessageHolders.IncomingTextMessageViewHolder<ChatMessage>(incomingView, payload) {
|
||||
private val binding: ItemCustomIncomingLocationMessageBinding =
|
||||
ItemCustomIncomingLocationMessageBinding.bind(itemView)
|
||||
|
||||
var locationLon: String? = ""
|
||||
var locationLat: String? = ""
|
||||
var locationName: String? = ""
|
||||
var locationGeoLink: String? = ""
|
||||
|
||||
@Inject
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var appPreferences: AppPreferences
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var dateUtils: DateUtils
|
||||
|
||||
lateinit var commonMessageInterface: CommonMessageInterface
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
|
||||
setAvatarAndAuthorOnMessageItem(message)
|
||||
|
||||
colorizeMessageBubble(message)
|
||||
|
||||
itemView.isSelected = false
|
||||
|
||||
val textSize = context.resources!!.getDimension(R.dimen.chat_text_size)
|
||||
binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
|
||||
binding.messageText.text = message.text
|
||||
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
|
||||
// parent message handling
|
||||
setParentMessageDataOnMessageItem(message)
|
||||
|
||||
// geo-location
|
||||
setLocationDataOnMessageItem(message)
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
::longClickOnReaction,
|
||||
binding.reactions,
|
||||
binding.messageText.context,
|
||||
false,
|
||||
viewThemeUtils
|
||||
)
|
||||
}
|
||||
|
||||
private fun longClickOnReaction(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.onLongClickReactions(chatMessage)
|
||||
}
|
||||
|
||||
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
|
||||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
|
||||
val actorName = message.actorDisplayName
|
||||
if (!actorName.isNullOrBlank()) {
|
||||
binding.messageAuthor.visibility = View.VISIBLE
|
||||
binding.messageAuthor.text = actorName
|
||||
binding.messageUserAvatar.setOnClickListener {
|
||||
(payload as? MessagePayload)?.profileBottomSheet?.showFor(message, itemView.context)
|
||||
}
|
||||
} else {
|
||||
binding.messageAuthor.setText(R.string.nc_nick_guest)
|
||||
}
|
||||
|
||||
if (!message.isGrouped && !message.isOneToOneConversation && !message.isFormerOneToOneConversation) {
|
||||
ChatMessageUtils().setAvatarOnMessage(binding.messageUserAvatar, message, viewThemeUtils)
|
||||
} else {
|
||||
if (message.isOneToOneConversation || message.isFormerOneToOneConversation) {
|
||||
binding.messageUserAvatar.visibility = View.GONE
|
||||
} else {
|
||||
binding.messageUserAvatar.visibility = View.INVISIBLE
|
||||
}
|
||||
binding.messageAuthor.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorizeMessageBubble(message: ChatMessage) {
|
||||
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val urlForChatting = ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
chatActivity.conversationUser?.baseUrl,
|
||||
chatActivity.roomToken
|
||||
)
|
||||
|
||||
val parentChatMessage = withContext(Dispatchers.IO) {
|
||||
chatActivity.chatViewModel.getMessageById(
|
||||
urlForChatting,
|
||||
chatActivity.currentConversation!!,
|
||||
message.parentMessageId!!
|
||||
).first()
|
||||
}
|
||||
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 = messageUtils
|
||||
.enrichChatReplyMessageText(
|
||||
binding.messageQuote.quotedMessage.context,
|
||||
parentChatMessage,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedMessageAuthor
|
||||
.setTextColor(context.resources.getColor(R.color.textColorMaxContrast, null))
|
||||
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
|
||||
private fun setLocationDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
|
||||
for (key in message.messageParameters!!.keys) {
|
||||
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
|
||||
if (individualHashMap["type"] == "geo-location") {
|
||||
locationLon = individualHashMap["longitude"]
|
||||
locationLat = individualHashMap["latitude"]
|
||||
locationName = individualHashMap["name"]
|
||||
locationGeoLink = individualHashMap["id"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.webview.settings.javaScriptEnabled = true
|
||||
|
||||
binding.webview.webViewClient = object : WebViewClient() {
|
||||
@Deprecated("Use shouldOverrideUrlLoading(WebView view, WebResourceRequest request)")
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean =
|
||||
if (url != null && UriUtils.hasHttpProtocolPrefixed(url)) {
|
||||
view?.context?.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val urlStringBuffer = StringBuffer("file:///android_asset/leafletMapMessagePreview.html")
|
||||
urlStringBuffer.append(
|
||||
"?mapProviderUrl=" + URLEncoder.encode(context.getString(R.string.osm_tile_server_url))
|
||||
)
|
||||
urlStringBuffer.append(
|
||||
"&mapProviderAttribution=" + URLEncoder.encode(context.getString(R.string.osm_tile_server_attributation))
|
||||
)
|
||||
urlStringBuffer.append("&locationLat=" + URLEncoder.encode(locationLat))
|
||||
urlStringBuffer.append("&locationLon=" + URLEncoder.encode(locationLon))
|
||||
urlStringBuffer.append("&locationName=" + URLEncoder.encode(locationName))
|
||||
urlStringBuffer.append("&locationGeoLink=" + URLEncoder.encode(locationGeoLink))
|
||||
|
||||
binding.webview.loadUrl(urlStringBuffer.toString())
|
||||
|
||||
binding.webview.setOnTouchListener(object : View.OnTouchListener {
|
||||
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
|
||||
when (event?.action) {
|
||||
MotionEvent.ACTION_UP -> openGeoLink()
|
||||
}
|
||||
|
||||
return v?.onTouchEvent(event) ?: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun openGeoLink() {
|
||||
if (!locationGeoLink.isNullOrEmpty()) {
|
||||
val geoLinkWithMarker = addMarkerToGeoLink(locationGeoLink!!)
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, geoLinkWithMarker.toUri())
|
||||
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(browserIntent)
|
||||
} else {
|
||||
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
|
||||
Log.e(TAG, "locationGeoLink was null or empty")
|
||||
}
|
||||
}
|
||||
|
||||
private fun addMarkerToGeoLink(locationGeoLink: String): String = locationGeoLink.replace("geo:", "geo:0,0?q=")
|
||||
|
||||
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
|
||||
this.commonMessageInterface = commonMessageInterface
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LocInMessageView"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import autodagger.AutoInjector
|
||||
import coil.load
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ItemCustomIncomingPollMessageBinding
|
||||
import com.nextcloud.talk.polls.ui.PollMainDialogFragment
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.ChatMessageUtils
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import com.stfalcon.chatkit.messages.MessageHolders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class IncomingPollMessageViewHolder(incomingView: View, payload: Any) :
|
||||
MessageHolders.IncomingTextMessageViewHolder<ChatMessage>(incomingView, payload) {
|
||||
|
||||
private val binding: ItemCustomIncomingPollMessageBinding = ItemCustomIncomingPollMessageBinding.bind(itemView)
|
||||
|
||||
@Inject
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var appPreferences: AppPreferences
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var dateUtils: DateUtils
|
||||
|
||||
@Inject
|
||||
lateinit var ncApi: NcApi
|
||||
|
||||
lateinit var message: ChatMessage
|
||||
|
||||
lateinit var commonMessageInterface: CommonMessageInterface
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
this.message = message
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
|
||||
setAvatarAndAuthorOnMessageItem(message)
|
||||
|
||||
colorizeMessageBubble(message)
|
||||
|
||||
itemView.isSelected = false
|
||||
|
||||
// parent message handling
|
||||
setParentMessageDataOnMessageItem(message)
|
||||
|
||||
setPollPreview(message)
|
||||
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
Thread().showThreadPreview(
|
||||
chatActivity,
|
||||
message,
|
||||
threadBinding = binding.threadTitleWrapper,
|
||||
reactionsBinding = binding.reactions,
|
||||
openThread = { openThread(message) }
|
||||
)
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
::longClickOnReaction,
|
||||
binding.reactions,
|
||||
binding.messageTime.context,
|
||||
false,
|
||||
viewThemeUtils
|
||||
)
|
||||
}
|
||||
|
||||
private fun longClickOnReaction(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.onLongClickReactions(chatMessage)
|
||||
}
|
||||
|
||||
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
|
||||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
private fun setPollPreview(message: ChatMessage) {
|
||||
var pollId: String? = null
|
||||
var pollName: String? = null
|
||||
|
||||
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
|
||||
for (key in message.messageParameters!!.keys) {
|
||||
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
|
||||
if (individualHashMap["type"] == "talk-poll") {
|
||||
pollId = individualHashMap["id"]
|
||||
pollName = individualHashMap["name"].toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pollId != null && pollName != null) {
|
||||
binding.messagePollTitle.text = pollName
|
||||
|
||||
val roomToken = (payload as? MessagePayload)!!.roomToken
|
||||
val isOwnerOrModerator = (payload as? MessagePayload)!!.isOwnerOrModerator ?: false
|
||||
|
||||
binding.bubble.setOnClickListener {
|
||||
val pollVoteDialog = PollMainDialogFragment.newInstance(
|
||||
message.activeUser!!,
|
||||
roomToken,
|
||||
isOwnerOrModerator,
|
||||
pollId,
|
||||
pollName
|
||||
)
|
||||
pollVoteDialog.show(
|
||||
(binding.messagePollIcon.context as ChatActivity).supportFragmentManager,
|
||||
TAG
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
|
||||
val actorName = message.actorDisplayName
|
||||
if (!actorName.isNullOrBlank()) {
|
||||
binding.messageAuthor.visibility = View.VISIBLE
|
||||
binding.messageAuthor.text = actorName
|
||||
binding.messageUserAvatar.setOnClickListener {
|
||||
(payload as? MessagePayload)?.profileBottomSheet?.showFor(message, itemView.context)
|
||||
}
|
||||
} else {
|
||||
binding.messageAuthor.setText(R.string.nc_nick_guest)
|
||||
}
|
||||
|
||||
if (!message.isGrouped && !message.isOneToOneConversation && !message.isFormerOneToOneConversation) {
|
||||
ChatMessageUtils().setAvatarOnMessage(binding.messageUserAvatar, message, viewThemeUtils)
|
||||
} else {
|
||||
if (message.isOneToOneConversation || message.isFormerOneToOneConversation) {
|
||||
binding.messageUserAvatar.visibility = View.GONE
|
||||
} else {
|
||||
binding.messageUserAvatar.visibility = View.INVISIBLE
|
||||
}
|
||||
binding.messageAuthor.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorizeMessageBubble(message: ChatMessage) {
|
||||
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val urlForChatting = ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
chatActivity.conversationUser?.baseUrl,
|
||||
chatActivity.roomToken
|
||||
)
|
||||
|
||||
val parentChatMessage = withContext(Dispatchers.IO) {
|
||||
chatActivity.chatViewModel.getMessageById(
|
||||
urlForChatting,
|
||||
chatActivity.currentConversation!!,
|
||||
message.parentMessageId!!
|
||||
).first()
|
||||
}
|
||||
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 = messageUtils
|
||||
.enrichChatReplyMessageText(
|
||||
binding.messageQuote.quotedMessage.context,
|
||||
parentChatMessage,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedMessageAuthor
|
||||
.setTextColor(ContextCompat.getColor(context, R.color.textColorMaxContrast))
|
||||
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
|
||||
this.commonMessageInterface = commonMessageInterface
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = IncomingPollMessageViewHolder::class.java.simpleName
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022-2023 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2021-2022 Tim Krüger <t@timkrueger.me>
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages;
|
||||
|
||||
import android.text.Spanned;
|
||||
import android.util.TypedValue;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.ProgressBar;
|
||||
|
||||
import com.google.android.material.card.MaterialCardView;
|
||||
import com.nextcloud.talk.R;
|
||||
import com.nextcloud.talk.databinding.ItemCustomIncomingPreviewMessageBinding;
|
||||
import com.nextcloud.talk.databinding.ItemThreadTitleBinding;
|
||||
import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding;
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage;
|
||||
import com.nextcloud.talk.utils.TextMatchers;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Objects;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.emoji2.widget.EmojiTextView;
|
||||
|
||||
public class IncomingPreviewMessageViewHolder extends PreviewMessageViewHolder {
|
||||
private final ItemCustomIncomingPreviewMessageBinding binding;
|
||||
|
||||
public IncomingPreviewMessageViewHolder(View itemView, Object payload) {
|
||||
super(itemView, payload);
|
||||
binding = ItemCustomIncomingPreviewMessageBinding.bind(itemView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBind(@NonNull ChatMessage message) {
|
||||
super.onBind(message);
|
||||
if(!message.isVoiceMessage()
|
||||
&& !Objects.equals(message.getMessage(), "{file}")
|
||||
) {
|
||||
Spanned processedMessageText = null;
|
||||
binding.incomingPreviewMessageBubble.setBackgroundResource(R.drawable.shape_grouped_incoming_message);
|
||||
if (viewThemeUtils != null ) {
|
||||
processedMessageText = messageUtils.enrichChatMessageText(
|
||||
binding.messageCaption.getContext(),
|
||||
message,
|
||||
true,
|
||||
viewThemeUtils);
|
||||
viewThemeUtils.talk.themeIncomingMessageBubble(binding.incomingPreviewMessageBubble, true, false,
|
||||
false);
|
||||
}
|
||||
|
||||
if (processedMessageText != null) {
|
||||
processedMessageText = messageUtils.processMessageParameters(
|
||||
binding.messageCaption.getContext(),
|
||||
viewThemeUtils,
|
||||
processedMessageText,
|
||||
message,
|
||||
binding.incomingPreviewMessageBubble);
|
||||
}
|
||||
binding.incomingPreviewMessageBubble.setOnClickListener(null);
|
||||
|
||||
float textSize = 0;
|
||||
if (context != null) {
|
||||
textSize = context.getResources().getDimension(R.dimen.chat_text_size);
|
||||
}
|
||||
HashMap<String, HashMap<String, String>> messageParameters = message.getMessageParameters();
|
||||
if (
|
||||
(messageParameters == null || messageParameters.size() <= 0) &&
|
||||
TextMatchers.isMessageWithSingleEmoticonOnly(message.getText())
|
||||
) {
|
||||
textSize = (float) (textSize * IncomingTextMessageViewHolder.TEXT_SIZE_MULTIPLIER);
|
||||
itemView.setSelected(true);
|
||||
}
|
||||
binding.messageCaption.setVisibility(View.VISIBLE);
|
||||
binding.messageCaption.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize);
|
||||
binding.messageCaption.setText(processedMessageText);
|
||||
} else {
|
||||
binding.incomingPreviewMessageBubble.setBackground(null);
|
||||
binding.messageCaption.setVisibility(View.GONE);
|
||||
}
|
||||
binding.messageAuthor.setText(message.getActorDisplayName());
|
||||
binding.messageText.setTextColor(ContextCompat.getColor(binding.messageText.getContext(),
|
||||
R.color.no_emphasis_text));
|
||||
binding.messageTime.setTextColor(ContextCompat.getColor(binding.messageText.getContext(),
|
||||
R.color.no_emphasis_text));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public EmojiTextView getMessageText() {
|
||||
return binding.messageText;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public EmojiTextView getMessageCaption() {
|
||||
return binding.messageCaption;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProgressBar getProgressBar() {
|
||||
return binding.progressBar;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public View getPreviewContainer() {
|
||||
return binding.previewContainer;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public MaterialCardView getPreviewContactContainer() {
|
||||
return binding.contactContainer;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ImageView getPreviewContactPhoto() {
|
||||
return binding.contactPhoto;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public EmojiTextView getPreviewContactName() {
|
||||
return binding.contactName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProgressBar getPreviewContactProgressBar() {
|
||||
return binding.contactProgressBar;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReactionsInsideMessageBinding getReactionsBinding(){ return binding.reactions; }
|
||||
|
||||
@Override
|
||||
public ItemThreadTitleBinding getThreadsBinding(){ return binding.threadTitleWrapper; }
|
||||
}
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2021-2023 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2021 Tim Krüger <t@timkrueger.me>
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.CheckBox
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.toSpanned
|
||||
import autodagger.AutoInjector
|
||||
import coil.load
|
||||
import com.google.android.flexbox.FlexboxLayout
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.nextcloud.android.common.ui.theme.utils.ColorRole
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.ChatMessageRepository
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.ItemCustomIncomingTextMessageBinding
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
|
||||
import com.nextcloud.talk.utils.ChatMessageUtils
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.SpreedFeatures
|
||||
import com.nextcloud.talk.utils.TextMatchers
|
||||
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import com.stfalcon.chatkit.messages.MessageHolders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
|
||||
MessageHolders.IncomingTextMessageViewHolder<ChatMessage>(itemView, payload) {
|
||||
|
||||
private val binding: ItemCustomIncomingTextMessageBinding = ItemCustomIncomingTextMessageBinding.bind(itemView)
|
||||
|
||||
@Inject
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var appPreferences: AppPreferences
|
||||
|
||||
@Inject
|
||||
lateinit var dateUtils: DateUtils
|
||||
|
||||
@Inject
|
||||
lateinit var currentUserProvider: CurrentUserProviderNew
|
||||
|
||||
lateinit var commonMessageInterface: CommonMessageInterface
|
||||
|
||||
@Inject
|
||||
lateinit var chatRepository: ChatMessageRepository
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
setAvatarAndAuthorOnMessageItem(message)
|
||||
colorizeMessageBubble(message)
|
||||
itemView.isSelected = false
|
||||
val user = currentUserProvider.currentUser.blockingGet()
|
||||
val hasCheckboxes = processCheckboxes(
|
||||
message,
|
||||
user
|
||||
)
|
||||
processMessage(message, hasCheckboxes)
|
||||
}
|
||||
|
||||
private fun processMessage(message: ChatMessage, hasCheckboxes: Boolean) {
|
||||
var textSize = context.resources!!.getDimension(R.dimen.chat_text_size)
|
||||
if (!hasCheckboxes) {
|
||||
binding.messageText.visibility = View.VISIBLE
|
||||
binding.checkboxContainer.visibility = View.GONE
|
||||
var processedMessageText = messageUtils.enrichChatMessageText(
|
||||
binding.messageText.context,
|
||||
message,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
val spansFromString: Array<Any> = processedMessageText!!.getSpans(
|
||||
0,
|
||||
processedMessageText.length,
|
||||
Any::class.java
|
||||
)
|
||||
|
||||
if (spansFromString.isNotEmpty()) {
|
||||
binding.bubble.layoutParams.apply {
|
||||
width = FlexboxLayout.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
binding.messageText.layoutParams.apply {
|
||||
width = FlexboxLayout.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
} else {
|
||||
binding.bubble.layoutParams.apply {
|
||||
width = FlexboxLayout.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
binding.messageText.layoutParams.apply {
|
||||
width = FlexboxLayout.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
}
|
||||
|
||||
processedMessageText = messageUtils.processMessageParameters(
|
||||
binding.messageText.context,
|
||||
viewThemeUtils,
|
||||
processedMessageText,
|
||||
message,
|
||||
itemView
|
||||
)
|
||||
val messageParameters = message.messageParameters
|
||||
if (
|
||||
(messageParameters == null || messageParameters.size <= 0) &&
|
||||
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 = processedMessageText
|
||||
// just for debugging:
|
||||
// binding.messageText.text =
|
||||
// SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")")
|
||||
} else {
|
||||
binding.messageText.visibility = View.GONE
|
||||
binding.checkboxContainer.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
if (message.lastEditTimestamp != 0L && !message.isDeleted) {
|
||||
binding.messageEditIndicator.visibility = View.VISIBLE
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.lastEditTimestamp!!)
|
||||
} else {
|
||||
binding.messageEditIndicator.visibility = View.GONE
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
}
|
||||
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
|
||||
|
||||
// parent message handling
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
processParentMessage(message)
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.setOnLongClickListener { l: View? ->
|
||||
commonMessageInterface.onOpenMessageActionsDialog(message)
|
||||
true
|
||||
}
|
||||
|
||||
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
|
||||
|
||||
Thread().showThreadPreview(
|
||||
chatActivity,
|
||||
message,
|
||||
threadBinding = binding.threadTitleWrapper,
|
||||
reactionsBinding = binding.reactions,
|
||||
openThread = { openThread(message) }
|
||||
)
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
::longClickOnReaction,
|
||||
binding.reactions,
|
||||
binding.messageText.context,
|
||||
false,
|
||||
viewThemeUtils
|
||||
)
|
||||
}
|
||||
|
||||
private fun processCheckboxes(chatMessage: ChatMessage, user: User): Boolean {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val message = chatMessage.message!!.toSpanned()
|
||||
val messageTextView = binding.messageText
|
||||
val checkBoxContainer = binding.checkboxContainer
|
||||
val isOlderThanTwentyFourHours = chatMessage
|
||||
.createdAt
|
||||
.before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_EDIT_MESSAGE))
|
||||
|
||||
val messageIsEditable = hasSpreedFeatureCapability(
|
||||
user.capabilities?.spreedCapability!!,
|
||||
SpreedFeatures.EDIT_MESSAGES
|
||||
) &&
|
||||
!isOlderThanTwentyFourHours
|
||||
|
||||
checkBoxContainer.removeAllViews()
|
||||
val regex = """(- \[(X|x| )])\s*(.+)""".toRegex(RegexOption.MULTILINE)
|
||||
val matches = regex.findAll(message)
|
||||
|
||||
if (matches.none()) return false
|
||||
|
||||
val firstPart = message.toString().substringBefore("\n- [")
|
||||
messageTextView.text = messageUtils.enrichChatMessageText(
|
||||
binding.messageText.context,
|
||||
firstPart,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
val checkboxList = mutableListOf<CheckBox>()
|
||||
|
||||
matches.forEach { matchResult ->
|
||||
val isChecked = matchResult.groupValues[CHECKED_GROUP_INDEX] == "X" ||
|
||||
matchResult.groupValues[CHECKED_GROUP_INDEX] == "x"
|
||||
val taskText = matchResult.groupValues[TASK_TEXT_GROUP_INDEX].trim()
|
||||
|
||||
val checkBox = CheckBox(checkBoxContainer.context).apply {
|
||||
text = taskText
|
||||
this.isChecked = isChecked
|
||||
this.isEnabled = (
|
||||
chatMessage.actorType == "bots" ||
|
||||
chatActivity.userAllowedByPrivilages(chatMessage)
|
||||
) &&
|
||||
messageIsEditable
|
||||
|
||||
setTextColor(ContextCompat.getColor(context, R.color.no_emphasis_text))
|
||||
|
||||
setOnCheckedChangeListener { _, _ ->
|
||||
updateCheckboxStates(chatMessage, user, checkboxList)
|
||||
}
|
||||
}
|
||||
checkBoxContainer.addView(checkBox)
|
||||
checkboxList.add(checkBox)
|
||||
viewThemeUtils.platform.themeCheckbox(checkBox)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateCheckboxStates(chatMessage: ChatMessage, user: User, checkboxes: List<CheckBox>) {
|
||||
job = CoroutineScope(Dispatchers.Main).launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val apiVersion: Int = ApiUtils.getChatApiVersion(
|
||||
user.capabilities?.spreedCapability!!,
|
||||
intArrayOf(1)
|
||||
)
|
||||
val updatedMessage = updateMessageWithCheckboxStates(chatMessage.message!!, checkboxes)
|
||||
chatRepository.editChatMessage(
|
||||
user.getCredentials(),
|
||||
ApiUtils.getUrlForChatMessage(apiVersion, user.baseUrl!!, chatMessage.token!!, chatMessage.id),
|
||||
updatedMessage
|
||||
).collect { result ->
|
||||
withContext(Dispatchers.Main) {
|
||||
if (result.isSuccess) {
|
||||
val editedMessage = result.getOrNull()?.ocs?.data!!.parentMessage!!
|
||||
Log.d(TAG, "EditedMessage: $editedMessage")
|
||||
binding.messageEditIndicator.apply {
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
binding.messageTime.text =
|
||||
dateUtils.getLocalTimeStringFromTimestamp(editedMessage.lastEditTimestamp!!)
|
||||
} else {
|
||||
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMessageWithCheckboxStates(originalMessage: String, checkboxes: List<CheckBox>): String {
|
||||
var updatedMessage = originalMessage
|
||||
val regex = """(- \[(X|x| )])\s*(.+)""".toRegex(RegexOption.MULTILINE)
|
||||
|
||||
checkboxes.forEach { _ ->
|
||||
updatedMessage = regex.replace(updatedMessage) { matchResult ->
|
||||
val taskText = matchResult.groupValues[TASK_TEXT_GROUP_INDEX].trim()
|
||||
val checkboxState = if (checkboxes.find { it.text == taskText }?.isChecked == true) "X" else " "
|
||||
"- [$checkboxState] $taskText"
|
||||
}
|
||||
}
|
||||
return updatedMessage
|
||||
}
|
||||
|
||||
private fun longClickOnReaction(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.onLongClickReactions(chatMessage)
|
||||
}
|
||||
|
||||
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
|
||||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
|
||||
val actorName = message.actorDisplayName
|
||||
if (!actorName.isNullOrBlank()) {
|
||||
binding.messageAuthor.visibility = View.VISIBLE
|
||||
binding.messageAuthor.text = actorName
|
||||
binding.messageUserAvatar.setOnClickListener {
|
||||
(payload as? MessagePayload)?.profileBottomSheet?.showFor(message, itemView.context)
|
||||
}
|
||||
} else {
|
||||
binding.messageAuthor.setText(R.string.nc_nick_guest)
|
||||
}
|
||||
|
||||
if (!message.isGrouped && !message.isOneToOneConversation && !message.isFormerOneToOneConversation) {
|
||||
ChatMessageUtils().setAvatarOnMessage(binding.messageUserAvatar, message, viewThemeUtils)
|
||||
} else {
|
||||
if (message.isOneToOneConversation || message.isFormerOneToOneConversation) {
|
||||
binding.messageUserAvatar.visibility = View.GONE
|
||||
} else {
|
||||
binding.messageUserAvatar.visibility = View.INVISIBLE
|
||||
}
|
||||
binding.messageAuthor.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorizeMessageBubble(message: ChatMessage) {
|
||||
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
private fun processParentMessage(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val urlForChatting = ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
chatActivity.conversationUser?.baseUrl,
|
||||
chatActivity.roomToken
|
||||
)
|
||||
|
||||
val parentChatMessage = withContext(Dispatchers.IO) {
|
||||
chatActivity.chatViewModel.getMessageById(
|
||||
urlForChatting,
|
||||
chatActivity.currentConversation!!,
|
||||
message.parentMessageId!!
|
||||
).first()
|
||||
}
|
||||
|
||||
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 = messageUtils
|
||||
.enrichChatReplyMessageText(
|
||||
binding.messageQuote.quotedMessage.context,
|
||||
parentChatMessage,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView,
|
||||
R.color.high_emphasis_text
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.setOnClickListener {
|
||||
chatActivity.jumpToQuotedMessage(parentChatMessage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
|
||||
this.commonMessageInterface = commonMessageInterface
|
||||
}
|
||||
|
||||
override fun viewDetached() {
|
||||
super.viewDetached()
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TEXT_SIZE_MULTIPLIER = 2.5
|
||||
private val TAG = IncomingTextMessageViewHolder::class.java.simpleName
|
||||
private const val CHECKED_GROUP_INDEX = 2
|
||||
private const val TASK_TEXT_GROUP_INDEX = 3
|
||||
private const val AGE_THRESHOLD_FOR_EDIT_MESSAGE: Long = 86400000
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,390 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2021 Tim Krüger <t@timkrueger.me>
|
||||
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.SeekBar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import autodagger.AutoInjector
|
||||
import coil.load
|
||||
import com.nextcloud.android.common.ui.theme.utils.ColorRole
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ItemCustomIncomingVoiceMessageBinding
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.ChatMessageUtils
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import com.stfalcon.chatkit.messages.MessageHolders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.ExecutionException
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
|
||||
MessageHolders.IncomingTextMessageViewHolder<ChatMessage>(incomingView, payload) {
|
||||
|
||||
private val binding: ItemCustomIncomingVoiceMessageBinding = ItemCustomIncomingVoiceMessageBinding.bind(itemView)
|
||||
|
||||
@JvmField
|
||||
@Inject
|
||||
var context: Context? = null
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var dateUtils: DateUtils
|
||||
|
||||
@Inject
|
||||
lateinit var appPreferences: AppPreferences
|
||||
|
||||
lateinit var message: ChatMessage
|
||||
|
||||
lateinit var voiceMessageInterface: VoiceMessageInterface
|
||||
lateinit var commonMessageInterface: CommonMessageInterface
|
||||
private var isBound = false
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
if (isBound) {
|
||||
handleIsPlayingVoiceMessageState(message)
|
||||
return
|
||||
}
|
||||
|
||||
this.message = message
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
|
||||
val filename = message.selectedIndividualHashMap!!["name"]
|
||||
val retrieved = appPreferences.getWaveFormFromFile(filename)
|
||||
if (retrieved.isNotEmpty() &&
|
||||
message.voiceMessageFloatArray == null ||
|
||||
message.voiceMessageFloatArray?.isEmpty() == true
|
||||
) {
|
||||
message.voiceMessageFloatArray = retrieved.toFloatArray()
|
||||
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
|
||||
}
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
|
||||
setAvatarAndAuthorOnMessageItem(message)
|
||||
|
||||
colorizeMessageBubble(message)
|
||||
|
||||
itemView.isSelected = false
|
||||
|
||||
// parent message handling
|
||||
setParentMessageDataOnMessageItem(message)
|
||||
|
||||
updateDownloadState(message)
|
||||
binding.seekbar.max = MAX
|
||||
viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar)
|
||||
viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT)
|
||||
|
||||
showVoiceMessageDuration(message)
|
||||
if (message.isDownloadingVoiceMessage) {
|
||||
showVoiceMessageLoading()
|
||||
} else {
|
||||
if (message.voiceMessageFloatArray == null || message.voiceMessageFloatArray!!.isEmpty()) {
|
||||
binding.seekbar.setWaveData(FloatArray(0))
|
||||
} else {
|
||||
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
|
||||
}
|
||||
binding.progressBar.visibility = View.GONE
|
||||
}
|
||||
|
||||
if (message.resetVoiceMessage) {
|
||||
resetVoiceMessage(message)
|
||||
}
|
||||
|
||||
binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
// unused atm
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar) {
|
||||
// unused atm
|
||||
}
|
||||
|
||||
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
|
||||
if (fromUser) {
|
||||
voiceMessageInterface.updateMediaPlayerProgressBySlider(message, progress)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
(voiceMessageInterface as ChatActivity).chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed ->
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.playbackSpeedControlBtn.setSpeed(speed)
|
||||
}
|
||||
}.collect()
|
||||
}
|
||||
|
||||
binding.playbackSpeedControlBtn.setSpeed(appPreferences.getPreferredPlayback(message.actorId))
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
::longClickOnReaction,
|
||||
binding.reactions,
|
||||
binding.messageTime.context,
|
||||
false,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
isBound = true
|
||||
}
|
||||
|
||||
private fun showVoiceMessageDuration(message: ChatMessage) {
|
||||
if (message.voiceMessageDuration > 0) {
|
||||
binding.voiceMessageDuration.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.voiceMessageDuration.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetVoiceMessage(chatMessage: ChatMessage) {
|
||||
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
|
||||
chatMessage.resetVoiceMessage = false
|
||||
chatMessage.voiceMessagePlayedSeconds = 0
|
||||
showVoiceMessageDuration(message)
|
||||
}
|
||||
|
||||
private fun longClickOnReaction(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.onLongClickReactions(chatMessage)
|
||||
}
|
||||
|
||||
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
|
||||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun handleIsPlayingVoiceMessageState(message: ChatMessage) {
|
||||
colorizeMessageBubble(message)
|
||||
if (message.isPlayingVoiceMessage) {
|
||||
showPlayButton()
|
||||
binding.playPauseBtn.icon = ContextCompat.getDrawable(
|
||||
context!!,
|
||||
R.drawable.ic_baseline_pause_voice_message_24
|
||||
)
|
||||
|
||||
val d = message.voiceMessageDuration.toLong()
|
||||
val t = message.voiceMessagePlayedSeconds.toLong()
|
||||
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
|
||||
binding.voiceMessageDuration.visibility = View.VISIBLE
|
||||
binding.seekbar.progress = message.voiceMessageSeekbarProgress
|
||||
} else {
|
||||
showVoiceMessageDuration(message)
|
||||
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.selectedIndividualHashMap!!["id"]
|
||||
val workers = WorkManager.getInstance(context!!).getWorkInfosByTag(fileId!!)
|
||||
|
||||
try {
|
||||
for (workInfo in workers.get()) {
|
||||
if (workInfo.state == WorkInfo.State.RUNNING || workInfo.state == WorkInfo.State.ENQUEUED) {
|
||||
showVoiceMessageLoading()
|
||||
WorkManager.getInstance(context!!).getWorkInfoByIdLiveData(workInfo.id)
|
||||
.observeForever { info: WorkInfo? ->
|
||||
showStatus(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: ExecutionException) {
|
||||
Log.e(TAG, "Error when checking if worker already exists", e)
|
||||
} catch (e: InterruptedException) {
|
||||
Log.e(TAG, "Error when checking if worker already exists", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showStatus(info: WorkInfo?) {
|
||||
if (info != null) {
|
||||
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
|
||||
}
|
||||
|
||||
private fun showVoiceMessageLoading() {
|
||||
binding.playPauseBtn.visibility = View.GONE
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
|
||||
val actorName = message.actorDisplayName
|
||||
if (!actorName.isNullOrBlank()) {
|
||||
binding.messageAuthor.visibility = View.VISIBLE
|
||||
binding.messageAuthor.text = actorName
|
||||
binding.messageUserAvatar.setOnClickListener {
|
||||
(payload as? MessagePayload)?.profileBottomSheet?.showFor(message, itemView.context)
|
||||
}
|
||||
} else {
|
||||
binding.messageAuthor.setText(R.string.nc_nick_guest)
|
||||
}
|
||||
|
||||
if (!message.isGrouped && !message.isOneToOneConversation && !message.isFormerOneToOneConversation) {
|
||||
ChatMessageUtils().setAvatarOnMessage(binding.messageUserAvatar, message, viewThemeUtils)
|
||||
} else {
|
||||
if (message.isOneToOneConversation || message.isFormerOneToOneConversation) {
|
||||
binding.messageUserAvatar.visibility = View.GONE
|
||||
} else {
|
||||
binding.messageUserAvatar.visibility = View.INVISIBLE
|
||||
}
|
||||
binding.messageAuthor.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorizeMessageBubble(message: ChatMessage) {
|
||||
viewThemeUtils.talk.themeIncomingMessageBubble(
|
||||
bubble,
|
||||
message.isGrouped,
|
||||
message.isDeleted,
|
||||
message.wasPlayedVoiceMessage
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val urlForChatting = ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
chatActivity.conversationUser?.baseUrl,
|
||||
chatActivity.roomToken
|
||||
)
|
||||
|
||||
val parentChatMessage = withContext(Dispatchers.IO) {
|
||||
chatActivity.chatViewModel.getMessageById(
|
||||
urlForChatting,
|
||||
chatActivity.currentConversation!!,
|
||||
message.parentMessageId!!
|
||||
).first()
|
||||
}
|
||||
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 = messageUtils
|
||||
.enrichChatReplyMessageText(
|
||||
binding.messageQuote.quotedMessage.context,
|
||||
parentChatMessage,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedMessageAuthor
|
||||
.setTextColor(ContextCompat.getColor(context!!, R.color.textColorMaxContrast))
|
||||
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
fun assignVoiceMessageInterface(voiceMessageInterface: VoiceMessageInterface) {
|
||||
this.voiceMessageInterface = voiceMessageInterface
|
||||
}
|
||||
|
||||
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
|
||||
this.commonMessageInterface = commonMessageInterface
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VoiceInMessageView"
|
||||
private const val SEEKBAR_START: Int = 0
|
||||
private const val MAX: Int = 100
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import coil.load
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ReferenceInsideMessageBinding
|
||||
import com.nextcloud.talk.models.json.opengraph.OpenGraphOverall
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import io.reactivex.Observer
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
|
||||
class LinkPreview {
|
||||
|
||||
fun showLink(message: ChatMessage, ncApi: NcApi, binding: ReferenceInsideMessageBinding, context: Context) {
|
||||
binding.referenceName.text = ""
|
||||
binding.referenceDescription.text = ""
|
||||
binding.referenceLink.text = ""
|
||||
binding.referenceThumbImage.setImageDrawable(null)
|
||||
|
||||
if (!message.extractedUrlToPreview.isNullOrEmpty()) {
|
||||
val credentials: String = ApiUtils.getCredentials(message.activeUser?.username, message.activeUser?.token)!!
|
||||
val openGraphLink = ApiUtils.getUrlForOpenGraph(message.activeUser?.baseUrl!!)
|
||||
ncApi.getOpenGraph(
|
||||
credentials,
|
||||
openGraphLink,
|
||||
message.extractedUrlToPreview
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(object : Observer<OpenGraphOverall> {
|
||||
override fun onSubscribe(d: Disposable) {
|
||||
// unused atm
|
||||
}
|
||||
|
||||
override fun onNext(openGraphOverall: OpenGraphOverall) {
|
||||
val reference = openGraphOverall.ocs?.data?.references?.entries?.iterator()?.next()?.value
|
||||
|
||||
if (reference != null) {
|
||||
val referenceName = reference.openGraphObject?.name
|
||||
if (!referenceName.isNullOrEmpty()) {
|
||||
binding.referenceName.visibility = View.VISIBLE
|
||||
binding.referenceName.text = referenceName
|
||||
} else {
|
||||
binding.referenceName.visibility = View.GONE
|
||||
}
|
||||
|
||||
val referenceDescription = reference.openGraphObject?.description
|
||||
if (!referenceDescription.isNullOrEmpty()) {
|
||||
binding.referenceDescription.visibility = View.VISIBLE
|
||||
binding.referenceDescription.text = referenceDescription
|
||||
} else {
|
||||
binding.referenceDescription.visibility = View.GONE
|
||||
}
|
||||
|
||||
val referenceLink = reference.openGraphObject?.link
|
||||
if (!referenceLink.isNullOrEmpty()) {
|
||||
binding.referenceLink.visibility = View.VISIBLE
|
||||
binding.referenceLink.text = referenceLink.replace(HTTPS_PROTOCOL, "")
|
||||
} else {
|
||||
binding.referenceLink.visibility = View.GONE
|
||||
}
|
||||
|
||||
val referenceThumbUrl = reference.openGraphObject?.thumb
|
||||
var backgroundId = R.drawable.link_text_background
|
||||
if (!referenceThumbUrl.isNullOrEmpty()) {
|
||||
binding.referenceThumbImage.visibility = View.VISIBLE
|
||||
binding.referenceThumbImage.load(referenceThumbUrl)
|
||||
} else {
|
||||
backgroundId = R.drawable.link_text_no_preview_background
|
||||
binding.referenceThumbImage.visibility = View.GONE
|
||||
}
|
||||
binding.referenceMetadataContainer.background = ContextCompat.getDrawable(
|
||||
binding.referenceMetadataContainer.context,
|
||||
backgroundId
|
||||
)
|
||||
|
||||
binding.referenceWrapper.setOnClickListener {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, referenceLink!!.toUri())
|
||||
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(browserIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
Log.e(TAG, "failed to get openGraph data", e)
|
||||
binding.referenceName.visibility = View.GONE
|
||||
binding.referenceDescription.visibility = View.GONE
|
||||
binding.referenceLink.visibility = View.GONE
|
||||
binding.referenceThumbImage.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun onComplete() {
|
||||
// unused atm
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = LinkPreview::class.java.simpleName
|
||||
private const val HTTPS_PROTOCOL = "https://"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet
|
||||
|
||||
data class MessagePayload(
|
||||
var roomToken: String,
|
||||
val isOwnerOrModerator: Boolean?,
|
||||
val profileBottomSheet: ProfileBottomSheet
|
||||
)
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
|
||||
* SPDX-FileCopyrightText: 2024 Sowjanya Kota<sowjanya.kch@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import autodagger.AutoInjector
|
||||
import coil.load
|
||||
import com.nextcloud.android.common.ui.theme.utils.ColorRole
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingDeckCardMessageBinding
|
||||
import com.nextcloud.talk.models.json.chat.ReadStatus
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import com.stfalcon.chatkit.messages.MessageHolders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class OutcomingDeckCardViewHolder(outcomingView: View) :
|
||||
MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(outcomingView),
|
||||
AdjustableMessageHolderInterface {
|
||||
|
||||
override val binding: ItemCustomOutcomingDeckCardMessageBinding = ItemCustomOutcomingDeckCardMessageBinding.bind(
|
||||
itemView
|
||||
)
|
||||
|
||||
@Inject
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var appPreferences: AppPreferences
|
||||
|
||||
@Inject
|
||||
lateinit var ncApi: NcApi
|
||||
|
||||
lateinit var message: ChatMessage
|
||||
|
||||
@Inject
|
||||
lateinit var dateUtils: DateUtils
|
||||
|
||||
lateinit var commonMessageInterface: CommonMessageInterface
|
||||
|
||||
var stackName: String? = null
|
||||
var cardName: String? = null
|
||||
var boardName: String? = null
|
||||
var cardLink: String? = null
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
this.message = message
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
|
||||
colorizeMessageBubble(message)
|
||||
|
||||
binding.cardView.findViewById<ImageView>(R.id.deckCardImage)?.let {
|
||||
viewThemeUtils.platform.colorImageView(it, ColorRole.SECONDARY)
|
||||
}
|
||||
|
||||
itemView.isSelected = false
|
||||
|
||||
showDeckCard(message)
|
||||
|
||||
// parent message handling
|
||||
setParentMessageDataOnMessageItem(message)
|
||||
|
||||
val readStatusDrawableInt = when (message.readStatus) {
|
||||
ReadStatus.READ -> R.drawable.ic_check_all
|
||||
ReadStatus.SENT -> R.drawable.ic_check
|
||||
else -> null
|
||||
}
|
||||
|
||||
val readStatusContentDescriptionString = when (message.readStatus) {
|
||||
ReadStatus.READ -> context.resources?.getString(R.string.nc_message_read)
|
||||
ReadStatus.SENT -> context.resources?.getString(R.string.nc_message_sent)
|
||||
else -> null
|
||||
}
|
||||
|
||||
readStatusDrawableInt?.let { drawableInt ->
|
||||
AppCompatResources.getDrawable(context, drawableInt)?.let {
|
||||
binding.checkMark.setImageDrawable(it)
|
||||
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark)
|
||||
}
|
||||
}
|
||||
|
||||
binding.checkMark.contentDescription = readStatusContentDescriptionString
|
||||
|
||||
binding.cardView.setOnClickListener {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, cardLink!!.toUri())
|
||||
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(browserIntent)
|
||||
}
|
||||
|
||||
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
::longClickOnReaction,
|
||||
binding.reactions,
|
||||
binding.messageTime.context,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
}
|
||||
|
||||
private fun showDeckCard(message: ChatMessage) {
|
||||
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
|
||||
for (key in message.messageParameters!!.keys) {
|
||||
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
|
||||
if (individualHashMap["type"] == "deck-card") {
|
||||
cardName = individualHashMap["name"]
|
||||
stackName = individualHashMap["stackname"]
|
||||
boardName = individualHashMap["boardname"]
|
||||
cardLink = individualHashMap["link"]
|
||||
}
|
||||
}
|
||||
}
|
||||
val cardDescription = String.format(
|
||||
context.resources.getString(R.string.deck_card_description),
|
||||
stackName,
|
||||
boardName
|
||||
)
|
||||
|
||||
binding.cardName.visibility = View.VISIBLE
|
||||
binding.cardDescription.visibility = View.VISIBLE
|
||||
binding.cardName.text = cardName
|
||||
binding.cardDescription.text = cardDescription
|
||||
}
|
||||
|
||||
private fun longClickOnReaction(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.onLongClickReactions(chatMessage)
|
||||
}
|
||||
|
||||
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
|
||||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val urlForChatting = ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
chatActivity.conversationUser?.baseUrl,
|
||||
chatActivity.roomToken
|
||||
)
|
||||
|
||||
val parentChatMessage = withContext(Dispatchers.IO) {
|
||||
chatActivity.chatViewModel.getMessageById(
|
||||
urlForChatting,
|
||||
chatActivity.currentConversation!!,
|
||||
message.parentMessageId!!
|
||||
).first()
|
||||
}
|
||||
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 = messageUtils
|
||||
.enrichChatReplyMessageText(
|
||||
binding.messageQuote.quotedMessage.context,
|
||||
parentChatMessage,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedMessageAuthor
|
||||
.setTextColor(ContextCompat.getColor(context, R.color.textColorMaxContrast))
|
||||
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorizeMessageBubble(message: ChatMessage) {
|
||||
viewThemeUtils.talk.themeOutgoingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
|
||||
this.commonMessageInterface = commonMessageInterface
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = OutcomingDeckCardViewHolder::class.java.simpleName
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import autodagger.AutoInjector
|
||||
import coil.load
|
||||
import com.nextcloud.android.common.ui.theme.utils.ColorRole
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingLinkPreviewMessageBinding
|
||||
import com.nextcloud.talk.models.json.chat.ReadStatus
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import com.stfalcon.chatkit.messages.MessageHolders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class OutcomingLinkPreviewMessageViewHolder(outcomingView: View, payload: Any) :
|
||||
MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(outcomingView, payload),
|
||||
AdjustableMessageHolderInterface {
|
||||
|
||||
override val binding: ItemCustomOutcomingLinkPreviewMessageBinding =
|
||||
ItemCustomOutcomingLinkPreviewMessageBinding.bind(itemView)
|
||||
|
||||
@Inject
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var dateUtils: DateUtils
|
||||
|
||||
@Inject
|
||||
lateinit var appPreferences: AppPreferences
|
||||
|
||||
@Inject
|
||||
lateinit var ncApi: NcApi
|
||||
|
||||
lateinit var message: ChatMessage
|
||||
|
||||
lateinit var commonMessageInterface: CommonMessageInterface
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
this.message = message
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
|
||||
colorizeMessageBubble(message)
|
||||
var processedMessageText =
|
||||
messageUtils.enrichChatMessageText(binding.messageText.context, message, false, viewThemeUtils)
|
||||
processedMessageText = messageUtils.processMessageParameters(
|
||||
binding.messageText.context,
|
||||
viewThemeUtils,
|
||||
processedMessageText!!,
|
||||
message,
|
||||
itemView
|
||||
)
|
||||
|
||||
binding.messageText.text = processedMessageText
|
||||
|
||||
itemView.isSelected = false
|
||||
|
||||
// parent message handling
|
||||
setParentMessageDataOnMessageItem(message)
|
||||
|
||||
val readStatusDrawableInt = when (message.readStatus) {
|
||||
ReadStatus.READ -> R.drawable.ic_check_all
|
||||
ReadStatus.SENT -> R.drawable.ic_check
|
||||
else -> null
|
||||
}
|
||||
|
||||
val readStatusContentDescriptionString = when (message.readStatus) {
|
||||
ReadStatus.READ -> context.resources?.getString(R.string.nc_message_read)
|
||||
ReadStatus.SENT -> context.resources?.getString(R.string.nc_message_sent)
|
||||
else -> null
|
||||
}
|
||||
|
||||
readStatusDrawableInt?.let { drawableInt ->
|
||||
AppCompatResources.getDrawable(context, drawableInt)?.let {
|
||||
binding.checkMark.setImageDrawable(it)
|
||||
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark)
|
||||
}
|
||||
}
|
||||
|
||||
binding.checkMark.contentDescription = readStatusContentDescriptionString
|
||||
|
||||
LinkPreview().showLink(
|
||||
message,
|
||||
ncApi,
|
||||
binding.referenceInclude,
|
||||
itemView.context
|
||||
)
|
||||
binding.referenceInclude.referenceWrapper.setOnLongClickListener { l: View? ->
|
||||
commonMessageInterface.onOpenMessageActionsDialog(message)
|
||||
true
|
||||
}
|
||||
|
||||
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
|
||||
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val showThreadButton = chatActivity.conversationThreadId == null && message.isThread
|
||||
if (showThreadButton) {
|
||||
binding.reactions.threadButton.visibility = View.VISIBLE
|
||||
binding.reactions.threadButton.setContent {
|
||||
ThreadButtonComposable(
|
||||
onButtonClick = { openThread(message) }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
binding.reactions.threadButton.visibility = View.GONE
|
||||
}
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
::longClickOnReaction,
|
||||
binding.reactions,
|
||||
binding.messageTime.context,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
}
|
||||
|
||||
private fun longClickOnReaction(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.onLongClickReactions(chatMessage)
|
||||
}
|
||||
|
||||
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
|
||||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val urlForChatting = ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
chatActivity.conversationUser?.baseUrl,
|
||||
chatActivity.roomToken
|
||||
)
|
||||
|
||||
val parentChatMessage = withContext(Dispatchers.IO) {
|
||||
chatActivity.chatViewModel.getMessageById(
|
||||
urlForChatting,
|
||||
chatActivity.currentConversation!!,
|
||||
message.parentMessageId!!
|
||||
).first()
|
||||
}
|
||||
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 = messageUtils
|
||||
.enrichChatReplyMessageText(
|
||||
binding.messageQuote.quotedMessage.context,
|
||||
parentChatMessage,
|
||||
false,
|
||||
viewThemeUtils
|
||||
)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorizeMessageBubble(message: ChatMessage) {
|
||||
viewThemeUtils.talk.themeOutgoingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
|
||||
this.commonMessageInterface = commonMessageInterface
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = OutcomingLinkPreviewMessageViewHolder::class.java.simpleName
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
|
||||
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.net.toUri
|
||||
import autodagger.AutoInjector
|
||||
import coil.load
|
||||
import com.google.android.flexbox.FlexboxLayout
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.nextcloud.android.common.ui.theme.utils.ColorRole
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingLocationMessageBinding
|
||||
import com.nextcloud.talk.models.json.chat.ReadStatus
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.UriUtils
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.stfalcon.chatkit.messages.MessageHolders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.URLEncoder
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class OutcomingLocationMessageViewHolder(incomingView: View) :
|
||||
MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(incomingView),
|
||||
AdjustableMessageHolderInterface {
|
||||
|
||||
override val binding: ItemCustomOutcomingLocationMessageBinding =
|
||||
ItemCustomOutcomingLocationMessageBinding.bind(itemView)
|
||||
private val realView: View = itemView
|
||||
|
||||
var locationLon: String? = ""
|
||||
var locationLat: String? = ""
|
||||
var locationName: String? = ""
|
||||
var locationGeoLink: String? = ""
|
||||
|
||||
@Inject
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var dateUtils: DateUtils
|
||||
|
||||
lateinit var commonMessageInterface: CommonMessageInterface
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
|
||||
realView.isSelected = false
|
||||
val layoutParams = binding.messageTime.layoutParams as FlexboxLayout.LayoutParams
|
||||
layoutParams.isWrapBefore = false
|
||||
|
||||
val textSize = context.resources.getDimension(R.dimen.chat_text_size)
|
||||
|
||||
colorizeMessageBubble(message)
|
||||
binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
|
||||
binding.messageTime.layoutParams = layoutParams
|
||||
|
||||
binding.messageText.text = message.text
|
||||
|
||||
// parent message handling
|
||||
setParentMessageDataOnMessageItem(message)
|
||||
|
||||
val readStatusDrawableInt = when (message.readStatus) {
|
||||
ReadStatus.READ -> R.drawable.ic_check_all
|
||||
ReadStatus.SENT -> R.drawable.ic_check
|
||||
else -> null
|
||||
}
|
||||
|
||||
val readStatusContentDescriptionString = when (message.readStatus) {
|
||||
ReadStatus.READ -> context.resources?.getString(R.string.nc_message_read)
|
||||
ReadStatus.SENT -> context.resources?.getString(R.string.nc_message_sent)
|
||||
else -> null
|
||||
}
|
||||
|
||||
readStatusDrawableInt?.let { drawableInt ->
|
||||
AppCompatResources.getDrawable(context, drawableInt)?.let {
|
||||
binding.checkMark.setImageDrawable(it)
|
||||
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark)
|
||||
}
|
||||
}
|
||||
|
||||
binding.checkMark.contentDescription = readStatusContentDescriptionString
|
||||
|
||||
// geo-location
|
||||
setLocationDataOnMessageItem(message)
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
::longClickOnReaction,
|
||||
binding.reactions,
|
||||
binding.messageText.context,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
}
|
||||
|
||||
private fun longClickOnReaction(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.onLongClickReactions(chatMessage)
|
||||
}
|
||||
|
||||
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
|
||||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
|
||||
private fun setLocationDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
|
||||
for (key in message.messageParameters!!.keys) {
|
||||
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
|
||||
if (individualHashMap["type"] == "geo-location") {
|
||||
locationLon = individualHashMap["longitude"]
|
||||
locationLat = individualHashMap["latitude"]
|
||||
locationName = individualHashMap["name"]
|
||||
locationGeoLink = individualHashMap["id"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.webview.settings.javaScriptEnabled = true
|
||||
|
||||
binding.webview.webViewClient = object : WebViewClient() {
|
||||
@Deprecated("Use shouldOverrideUrlLoading(WebView view, WebResourceRequest request)")
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean =
|
||||
if (url != null && UriUtils.hasHttpProtocolPrefixed(url)) {
|
||||
view?.context?.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val urlStringBuffer = StringBuffer("file:///android_asset/leafletMapMessagePreview.html")
|
||||
urlStringBuffer.append(
|
||||
"?mapProviderUrl=" + URLEncoder.encode(context.getString(R.string.osm_tile_server_url))
|
||||
)
|
||||
urlStringBuffer.append(
|
||||
"&mapProviderAttribution=" + URLEncoder.encode(
|
||||
context.getString(
|
||||
R.string
|
||||
.osm_tile_server_attributation
|
||||
)
|
||||
)
|
||||
)
|
||||
urlStringBuffer.append("&locationLat=" + URLEncoder.encode(locationLat))
|
||||
urlStringBuffer.append("&locationLon=" + URLEncoder.encode(locationLon))
|
||||
urlStringBuffer.append("&locationName=" + URLEncoder.encode(locationName))
|
||||
urlStringBuffer.append("&locationGeoLink=" + URLEncoder.encode(locationGeoLink))
|
||||
|
||||
binding.webview.loadUrl(urlStringBuffer.toString())
|
||||
|
||||
binding.webview.setOnTouchListener(object : View.OnTouchListener {
|
||||
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
|
||||
when (event?.action) {
|
||||
MotionEvent.ACTION_UP -> openGeoLink()
|
||||
}
|
||||
|
||||
return v?.onTouchEvent(event) ?: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val urlForChatting = ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
chatActivity.conversationUser?.baseUrl,
|
||||
chatActivity.roomToken
|
||||
)
|
||||
|
||||
val parentChatMessage = withContext(Dispatchers.IO) {
|
||||
chatActivity.chatViewModel.getMessageById(
|
||||
urlForChatting,
|
||||
chatActivity.currentConversation!!,
|
||||
message.parentMessageId!!
|
||||
).first()
|
||||
}
|
||||
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 = messageUtils
|
||||
.enrichChatReplyMessageText(
|
||||
binding.messageQuote.quotedMessage.context,
|
||||
parentChatMessage,
|
||||
false,
|
||||
viewThemeUtils
|
||||
)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorizeMessageBubble(message: ChatMessage) {
|
||||
viewThemeUtils.talk.themeOutgoingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
private fun openGeoLink() {
|
||||
if (!locationGeoLink.isNullOrEmpty()) {
|
||||
val geoLinkWithMarker = addMarkerToGeoLink(locationGeoLink!!)
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, geoLinkWithMarker.toUri())
|
||||
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(browserIntent)
|
||||
} else {
|
||||
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
|
||||
Log.e(TAG, "locationGeoLink was null or empty")
|
||||
}
|
||||
}
|
||||
|
||||
private fun addMarkerToGeoLink(locationGeoLink: String): String = locationGeoLink.replace("geo:", "geo:0,0?q=")
|
||||
|
||||
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
|
||||
this.commonMessageInterface = commonMessageInterface
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LocOutMessageView"
|
||||
private const val HALF_ALPHA_INT: Int = 255 / 2
|
||||
private val ALPHA_60_INT: Int = (255 * 0.6).roundToInt()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import autodagger.AutoInjector
|
||||
import coil.load
|
||||
import com.nextcloud.android.common.ui.theme.utils.ColorRole
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingPollMessageBinding
|
||||
import com.nextcloud.talk.models.json.chat.ReadStatus
|
||||
import com.nextcloud.talk.polls.ui.PollMainDialogFragment
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import com.stfalcon.chatkit.messages.MessageHolders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class OutcomingPollMessageViewHolder(outcomingView: View, payload: Any) :
|
||||
MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(outcomingView, payload),
|
||||
AdjustableMessageHolderInterface {
|
||||
|
||||
override val binding: ItemCustomOutcomingPollMessageBinding = ItemCustomOutcomingPollMessageBinding.bind(itemView)
|
||||
|
||||
@Inject
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var dateUtils: DateUtils
|
||||
|
||||
@Inject
|
||||
lateinit var appPreferences: AppPreferences
|
||||
|
||||
@Inject
|
||||
lateinit var ncApi: NcApi
|
||||
|
||||
lateinit var message: ChatMessage
|
||||
|
||||
lateinit var commonMessageInterface: CommonMessageInterface
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
this.message = message
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
|
||||
colorizeMessageBubble(message)
|
||||
|
||||
itemView.isSelected = false
|
||||
|
||||
// parent message handling
|
||||
setParentMessageDataOnMessageItem(message)
|
||||
|
||||
val readStatusDrawableInt = when (message.readStatus) {
|
||||
ReadStatus.READ -> R.drawable.ic_check_all
|
||||
ReadStatus.SENT -> R.drawable.ic_check
|
||||
else -> null
|
||||
}
|
||||
|
||||
val readStatusContentDescriptionString = when (message.readStatus) {
|
||||
ReadStatus.READ -> context.resources?.getString(R.string.nc_message_read)
|
||||
ReadStatus.SENT -> context.resources?.getString(R.string.nc_message_sent)
|
||||
else -> null
|
||||
}
|
||||
|
||||
readStatusDrawableInt?.let { drawableInt ->
|
||||
AppCompatResources.getDrawable(context, drawableInt)?.let {
|
||||
binding.checkMark.setImageDrawable(it)
|
||||
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark)
|
||||
}
|
||||
}
|
||||
|
||||
binding.checkMark.contentDescription = readStatusContentDescriptionString
|
||||
|
||||
setPollPreview(message)
|
||||
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
Thread().showThreadPreview(
|
||||
chatActivity,
|
||||
message,
|
||||
threadBinding = binding.threadTitleWrapper,
|
||||
reactionsBinding = binding.reactions,
|
||||
openThread = { openThread(message) }
|
||||
)
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
::longClickOnReaction,
|
||||
binding.reactions,
|
||||
binding.messageTime.context,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
}
|
||||
|
||||
private fun longClickOnReaction(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.onLongClickReactions(chatMessage)
|
||||
}
|
||||
|
||||
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
|
||||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
private fun setPollPreview(message: ChatMessage) {
|
||||
var pollId: String? = null
|
||||
var pollName: String? = null
|
||||
|
||||
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
|
||||
for (key in message.messageParameters!!.keys) {
|
||||
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
|
||||
if (individualHashMap["type"] == "talk-poll") {
|
||||
pollId = individualHashMap["id"]
|
||||
pollName = individualHashMap["name"].toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pollId != null && pollName != null) {
|
||||
binding.messagePollTitle.text = pollName
|
||||
|
||||
val roomToken = (payload as? MessagePayload)!!.roomToken
|
||||
val isOwnerOrModerator = (payload as? MessagePayload)!!.isOwnerOrModerator ?: false
|
||||
|
||||
binding.bubble.setOnClickListener {
|
||||
val pollVoteDialog = PollMainDialogFragment.newInstance(
|
||||
message.activeUser!!,
|
||||
roomToken,
|
||||
isOwnerOrModerator,
|
||||
pollId,
|
||||
pollName
|
||||
)
|
||||
pollVoteDialog.show(
|
||||
(binding.messagePollIcon.context as ChatActivity).supportFragmentManager,
|
||||
TAG
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val urlForChatting = ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
chatActivity.conversationUser?.baseUrl,
|
||||
chatActivity.roomToken
|
||||
)
|
||||
|
||||
val parentChatMessage = withContext(Dispatchers.IO) {
|
||||
chatActivity.chatViewModel.getMessageById(
|
||||
urlForChatting,
|
||||
chatActivity.currentConversation!!,
|
||||
message.parentMessageId!!
|
||||
).first()
|
||||
}
|
||||
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 = messageUtils
|
||||
.enrichChatReplyMessageText(
|
||||
binding.messageQuote.quotedMessage.context,
|
||||
parentChatMessage,
|
||||
false,
|
||||
viewThemeUtils
|
||||
)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorizeMessageBubble(message: ChatMessage) {
|
||||
viewThemeUtils.talk.themeOutgoingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
|
||||
this.commonMessageInterface = commonMessageInterface
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = OutcomingPollMessageViewHolder::class.java.simpleName
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2021 Tim Krüger <t@timkrueger.me>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages;
|
||||
|
||||
import android.text.Spanned;
|
||||
import android.util.TypedValue;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.ProgressBar;
|
||||
|
||||
import com.google.android.material.card.MaterialCardView;
|
||||
import com.nextcloud.talk.R;
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingPreviewMessageBinding;
|
||||
import com.nextcloud.talk.databinding.ItemThreadTitleBinding;
|
||||
import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding;
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage;
|
||||
import com.nextcloud.talk.utils.TextMatchers;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Objects;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.emoji2.widget.EmojiTextView;
|
||||
|
||||
public class OutcomingPreviewMessageViewHolder extends PreviewMessageViewHolder {
|
||||
|
||||
private final ItemCustomOutcomingPreviewMessageBinding binding;
|
||||
|
||||
public OutcomingPreviewMessageViewHolder(View itemView) {
|
||||
super(itemView, null);
|
||||
binding = ItemCustomOutcomingPreviewMessageBinding.bind(itemView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBind(@NonNull ChatMessage message) {
|
||||
super.onBind(message);
|
||||
if(!message.isVoiceMessage()
|
||||
&& !Objects.equals(message.getMessage(), "{file}")
|
||||
) {
|
||||
Spanned processedMessageText = null;
|
||||
binding.outgoingPreviewMessageBubble.setBackgroundResource(R.drawable.shape_grouped_outcoming_message);
|
||||
if (viewThemeUtils != null) {
|
||||
processedMessageText = messageUtils.enrichChatMessageText(
|
||||
binding.messageCaption.getContext(),
|
||||
message,
|
||||
false,
|
||||
viewThemeUtils);
|
||||
viewThemeUtils.talk.themeOutgoingMessageBubble(binding.outgoingPreviewMessageBubble, true, false,
|
||||
false);
|
||||
}
|
||||
|
||||
if (processedMessageText != null) {
|
||||
processedMessageText = messageUtils.processMessageParameters(
|
||||
binding.messageCaption.getContext(),
|
||||
viewThemeUtils,
|
||||
processedMessageText,
|
||||
message,
|
||||
binding.outgoingPreviewMessageBubble);
|
||||
}
|
||||
binding.outgoingPreviewMessageBubble.setOnClickListener(null);
|
||||
|
||||
float textSize = 0;
|
||||
if (context != null) {
|
||||
textSize = context.getResources().getDimension(R.dimen.chat_text_size);
|
||||
}
|
||||
HashMap<String, HashMap<String, String>> messageParameters = message.getMessageParameters();
|
||||
if (
|
||||
(messageParameters == null || messageParameters.size() <= 0) &&
|
||||
TextMatchers.isMessageWithSingleEmoticonOnly(message.getText())
|
||||
) {
|
||||
textSize = (float)(textSize * IncomingTextMessageViewHolder.TEXT_SIZE_MULTIPLIER);
|
||||
itemView.setSelected(true);
|
||||
}
|
||||
binding.messageCaption.setVisibility(View.VISIBLE);
|
||||
binding.messageCaption.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize);
|
||||
binding.messageCaption.setText(processedMessageText);
|
||||
} else {
|
||||
binding.outgoingPreviewMessageBubble.setBackground(null);
|
||||
binding.messageCaption.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
binding.messageText.setTextColor(ContextCompat.getColor(binding.messageText.getContext(),
|
||||
R.color.no_emphasis_text));
|
||||
binding.messageTime.setTextColor(ContextCompat.getColor(binding.messageText.getContext(),
|
||||
R.color.no_emphasis_text));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public EmojiTextView getMessageText() {
|
||||
return binding.messageText;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProgressBar getProgressBar() {
|
||||
return binding.progressBar;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public View getPreviewContainer() {
|
||||
return binding.previewContainer;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public MaterialCardView getPreviewContactContainer() {
|
||||
return binding.contactContainer;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ImageView getPreviewContactPhoto() {
|
||||
return binding.contactPhoto;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public EmojiTextView getPreviewContactName() {
|
||||
return binding.contactName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProgressBar getPreviewContactProgressBar() {
|
||||
return binding.contactProgressBar;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReactionsInsideMessageBinding getReactionsBinding() { return binding.reactions; }
|
||||
|
||||
@Override
|
||||
public ItemThreadTitleBinding getThreadsBinding(){ return binding.threadTitleWrapper; }
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public EmojiTextView getMessageCaption() { return binding.messageCaption; }
|
||||
}
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.CheckBox
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.text.toSpanned
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import autodagger.AutoInjector
|
||||
import coil.load
|
||||
import com.google.android.flexbox.FlexboxLayout
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.nextcloud.android.common.ui.theme.utils.ColorRole
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.ChatMessageRepository
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.data.database.model.SendStatus
|
||||
import com.nextcloud.talk.data.network.NetworkMonitor
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingTextMessageBinding
|
||||
import com.nextcloud.talk.models.json.chat.ReadStatus
|
||||
import com.nextcloud.talk.models.json.conversations.ConversationEnums
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.SpreedFeatures
|
||||
import com.nextcloud.talk.utils.TextMatchers
|
||||
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.stfalcon.chatkit.messages.MessageHolders.OutcomingTextMessageViewHolder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class OutcomingTextMessageViewHolder(itemView: View) :
|
||||
OutcomingTextMessageViewHolder<ChatMessage>(itemView),
|
||||
AdjustableMessageHolderInterface {
|
||||
|
||||
override val binding: ItemCustomOutcomingTextMessageBinding = ItemCustomOutcomingTextMessageBinding.bind(itemView)
|
||||
private val realView: View = itemView
|
||||
|
||||
@Inject
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var dateUtils: DateUtils
|
||||
|
||||
@Inject
|
||||
lateinit var networkMonitor: NetworkMonitor
|
||||
|
||||
lateinit var commonMessageInterface: CommonMessageInterface
|
||||
|
||||
@Inject
|
||||
lateinit var chatRepository: ChatMessageRepository
|
||||
|
||||
@Inject
|
||||
lateinit var currentUserProvider: CurrentUserProviderNew
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
@Suppress("Detekt.LongMethod")
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
val user = currentUserProvider.currentUser.blockingGet()
|
||||
val hasCheckboxes = processCheckboxes(
|
||||
message,
|
||||
user
|
||||
)
|
||||
processMessage(message, hasCheckboxes)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.LongMethod")
|
||||
private fun processMessage(message: ChatMessage, hasCheckboxes: Boolean) {
|
||||
var isBubbled = true
|
||||
val layoutParams = binding.messageTime.layoutParams as FlexboxLayout.LayoutParams
|
||||
var textSize = context.resources.getDimension(R.dimen.chat_text_size)
|
||||
if (!hasCheckboxes) {
|
||||
realView.isSelected = false
|
||||
layoutParams.isWrapBefore = false
|
||||
|
||||
binding.messageText.visibility = View.VISIBLE
|
||||
binding.checkboxContainer.visibility = View.GONE
|
||||
|
||||
var processedMessageText = messageUtils.enrichChatMessageText(
|
||||
binding.messageText.context,
|
||||
message,
|
||||
false,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
val spansFromString: Array<Any> = processedMessageText!!.getSpans(
|
||||
0,
|
||||
processedMessageText.length,
|
||||
Any::class.java
|
||||
)
|
||||
|
||||
if (spansFromString.isNotEmpty()) {
|
||||
binding.bubble.layoutParams.apply {
|
||||
width = FlexboxLayout.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
binding.messageText.layoutParams.apply {
|
||||
width = FlexboxLayout.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
} else {
|
||||
binding.bubble.layoutParams.apply {
|
||||
width = FlexboxLayout.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
binding.messageText.layoutParams.apply {
|
||||
width = FlexboxLayout.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
}
|
||||
|
||||
processedMessageText = messageUtils.processMessageParameters(
|
||||
binding.messageText.context,
|
||||
viewThemeUtils,
|
||||
processedMessageText,
|
||||
message,
|
||||
itemView
|
||||
)
|
||||
|
||||
if (
|
||||
(message.messageParameters == null || message.messageParameters!!.size <= 0) &&
|
||||
TextMatchers.isMessageWithSingleEmoticonOnly(message.text)
|
||||
) {
|
||||
textSize = (textSize * TEXT_SIZE_MULTIPLIER).toFloat()
|
||||
layoutParams.isWrapBefore = true
|
||||
realView.isSelected = true
|
||||
isBubbled = false
|
||||
}
|
||||
|
||||
binding.messageTime.layoutParams = layoutParams
|
||||
viewThemeUtils.platform.colorTextView(binding.messageText, ColorRole.ON_SURFACE_VARIANT)
|
||||
binding.messageText.text = processedMessageText
|
||||
// just for debugging:
|
||||
// binding.messageText.text =
|
||||
// SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")")
|
||||
} else {
|
||||
binding.messageText.visibility = View.GONE
|
||||
binding.checkboxContainer.visibility = View.VISIBLE
|
||||
}
|
||||
binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
|
||||
if (message.lastEditTimestamp != 0L && !message.isDeleted) {
|
||||
binding.messageEditIndicator.visibility = View.VISIBLE
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.lastEditTimestamp!!)
|
||||
} else {
|
||||
binding.messageEditIndicator.visibility = View.GONE
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
}
|
||||
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
|
||||
setBubbleOnChatMessage(message)
|
||||
|
||||
// parent message handling
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
processParentMessage(message)
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.setOnLongClickListener { l: View? ->
|
||||
commonMessageInterface.onOpenMessageActionsDialog(message)
|
||||
true
|
||||
}
|
||||
|
||||
binding.checkMark.visibility = View.INVISIBLE
|
||||
binding.sendingProgress.visibility = View.GONE
|
||||
|
||||
if (message.sendStatus == SendStatus.FAILED) {
|
||||
updateStatus(R.drawable.baseline_error_outline_24, context.resources?.getString(R.string.nc_message_failed))
|
||||
} else if (message.isTemporary) {
|
||||
updateStatus(R.drawable.baseline_schedule_24, context.resources?.getString(R.string.nc_message_sending))
|
||||
} else if (message.readStatus == ReadStatus.READ) {
|
||||
updateStatus(R.drawable.ic_check_all, context.resources?.getString(R.string.nc_message_read))
|
||||
} else if (message.readStatus == ReadStatus.SENT) {
|
||||
updateStatus(R.drawable.ic_check, context.resources?.getString(R.string.nc_message_sent))
|
||||
}
|
||||
|
||||
chatActivity.lifecycleScope.launch {
|
||||
if (message.isTemporary && !networkMonitor.isOnline.value) {
|
||||
updateStatus(
|
||||
R.drawable.ic_signal_wifi_off_white_24dp,
|
||||
context.resources?.getString(R.string.nc_message_offline)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
|
||||
|
||||
Thread().showThreadPreview(
|
||||
chatActivity,
|
||||
message,
|
||||
threadBinding = binding.threadTitleWrapper,
|
||||
reactionsBinding = binding.reactions,
|
||||
openThread = { openThread(message) }
|
||||
)
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
::longClickOnReaction,
|
||||
binding.reactions,
|
||||
context,
|
||||
true,
|
||||
viewThemeUtils,
|
||||
isBubbled
|
||||
)
|
||||
}
|
||||
|
||||
private fun processCheckboxes(chatMessage: ChatMessage, user: User): Boolean {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val message = chatMessage.message!!.toSpanned()
|
||||
val messageTextView = binding.messageText
|
||||
val checkBoxContainer = binding.checkboxContainer
|
||||
val isOlderThanTwentyFourHours = chatMessage
|
||||
.createdAt
|
||||
.before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_EDIT_MESSAGE))
|
||||
val messageIsEditable = hasSpreedFeatureCapability(
|
||||
user.capabilities?.spreedCapability!!,
|
||||
SpreedFeatures.EDIT_MESSAGES
|
||||
) &&
|
||||
!isOlderThanTwentyFourHours
|
||||
|
||||
val isNoTimeLimitOnNoteToSelf = hasSpreedFeatureCapability(
|
||||
user.capabilities?.spreedCapability!!,
|
||||
SpreedFeatures
|
||||
.EDIT_MESSAGES_NOTE_TO_SELF
|
||||
) &&
|
||||
chatActivity.currentConversation?.type == ConversationEnums.ConversationType.NOTE_TO_SELF
|
||||
|
||||
checkBoxContainer.removeAllViews()
|
||||
val regex = """(- \[(X|x| )])\s*(.+)""".toRegex(RegexOption.MULTILINE)
|
||||
val matches = regex.findAll(message)
|
||||
|
||||
if (matches.none()) return false
|
||||
|
||||
val firstPart = message.toString().substringBefore("\n- [")
|
||||
messageTextView.text = messageUtils.enrichChatMessageText(
|
||||
binding.messageText.context,
|
||||
firstPart,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
val checkboxList = mutableListOf<CheckBox>()
|
||||
|
||||
matches.forEach { matchResult ->
|
||||
val isChecked = matchResult.groupValues[CHECKED_GROUP_INDEX] == "X" ||
|
||||
matchResult.groupValues[CHECKED_GROUP_INDEX] == "x"
|
||||
val taskText = matchResult.groupValues[TASK_TEXT_GROUP_INDEX].trim()
|
||||
|
||||
val checkBox = CheckBox(checkBoxContainer.context).apply {
|
||||
text = taskText
|
||||
this.isChecked = isChecked
|
||||
this.isEnabled = messageIsEditable || isNoTimeLimitOnNoteToSelf
|
||||
|
||||
setTextColor(ContextCompat.getColor(context, R.color.no_emphasis_text))
|
||||
|
||||
setOnCheckedChangeListener { _, _ ->
|
||||
updateCheckboxStates(chatMessage, user, checkboxList)
|
||||
}
|
||||
}
|
||||
checkBoxContainer.addView(checkBox)
|
||||
checkboxList.add(checkBox)
|
||||
viewThemeUtils.platform.themeCheckbox(checkBox)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateCheckboxStates(chatMessage: ChatMessage, user: User, checkboxes: List<CheckBox>) {
|
||||
job = CoroutineScope(Dispatchers.Main).launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val apiVersion: Int = ApiUtils.getChatApiVersion(
|
||||
user.capabilities?.spreedCapability!!,
|
||||
intArrayOf(1)
|
||||
)
|
||||
val updatedMessage = updateMessageWithCheckboxStates(chatMessage.message!!, checkboxes)
|
||||
chatRepository.editChatMessage(
|
||||
user.getCredentials(),
|
||||
ApiUtils.getUrlForChatMessage(apiVersion, user.baseUrl!!, chatMessage.token!!, chatMessage.id),
|
||||
updatedMessage
|
||||
).collect { result ->
|
||||
withContext(Dispatchers.Main) {
|
||||
if (result.isSuccess) {
|
||||
val editedMessage = result.getOrNull()?.ocs?.data!!.parentMessage!!
|
||||
Log.d(TAG, "EditedMessage: $editedMessage")
|
||||
binding.messageEditIndicator.apply {
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
binding.messageTime.text =
|
||||
dateUtils.getLocalTimeStringFromTimestamp(editedMessage.lastEditTimestamp!!)
|
||||
} else {
|
||||
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMessageWithCheckboxStates(originalMessage: String, checkboxes: List<CheckBox>): String {
|
||||
var updatedMessage = originalMessage
|
||||
val regex = """(- \[(X|x| )])\s*(.+)""".toRegex(RegexOption.MULTILINE)
|
||||
|
||||
checkboxes.forEach { _ ->
|
||||
updatedMessage = regex.replace(updatedMessage) { matchResult ->
|
||||
val taskText = matchResult.groupValues[TASK_TEXT_GROUP_INDEX].trim()
|
||||
val checkboxState = if (checkboxes.find { it.text == taskText }?.isChecked == true) "X" else " "
|
||||
"- [$checkboxState] $taskText"
|
||||
}
|
||||
}
|
||||
return updatedMessage
|
||||
}
|
||||
|
||||
private fun updateStatus(readStatusDrawableInt: Int, description: String?) {
|
||||
binding.sendingProgress.visibility = View.GONE
|
||||
binding.checkMark.visibility = View.VISIBLE
|
||||
readStatusDrawableInt.let { drawableInt ->
|
||||
ResourcesCompat.getDrawable(context.resources, drawableInt, null)?.let {
|
||||
binding.checkMark.setImageDrawable(it)
|
||||
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark)
|
||||
}
|
||||
}
|
||||
binding.checkMark.contentDescription = description
|
||||
}
|
||||
|
||||
private fun longClickOnReaction(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.onLongClickReactions(chatMessage)
|
||||
}
|
||||
|
||||
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
|
||||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
private fun processParentMessage(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val urlForChatting = ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
chatActivity.conversationUser?.baseUrl,
|
||||
chatActivity.roomToken
|
||||
)
|
||||
|
||||
val parentChatMessage = withContext(Dispatchers.IO) {
|
||||
chatActivity.chatViewModel.getMessageById(
|
||||
urlForChatting,
|
||||
chatActivity.currentConversation!!,
|
||||
message.parentMessageId!!
|
||||
).first()
|
||||
}
|
||||
|
||||
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 = messageUtils
|
||||
.enrichChatReplyMessageText(
|
||||
binding.messageQuote.quotedMessage.context,
|
||||
parentChatMessage,
|
||||
false,
|
||||
viewThemeUtils
|
||||
)
|
||||
|
||||
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.setOnClickListener {
|
||||
chatActivity.jumpToQuotedMessage(parentChatMessage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setBubbleOnChatMessage(message: ChatMessage) {
|
||||
viewThemeUtils.talk.themeOutgoingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
|
||||
this.commonMessageInterface = commonMessageInterface
|
||||
}
|
||||
|
||||
override fun viewDetached() {
|
||||
super.viewDetached()
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TEXT_SIZE_MULTIPLIER = 2.5
|
||||
private val TAG = OutcomingTextMessageViewHolder::class.java.simpleName
|
||||
private const val CHECKED_GROUP_INDEX = 2
|
||||
private const val TASK_TEXT_GROUP_INDEX = 3
|
||||
private const val AGE_THRESHOLD_FOR_EDIT_MESSAGE: Long = 86400000
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
|
||||
* SPDX-FileCopyrightText: 2023 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2023 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.SeekBar
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import autodagger.AutoInjector
|
||||
import coil.load
|
||||
import com.nextcloud.android.common.ui.theme.utils.ColorRole
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingVoiceMessageBinding
|
||||
import com.nextcloud.talk.models.json.chat.ReadStatus
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import com.stfalcon.chatkit.messages.MessageHolders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.ExecutionException
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
@Suppress("Detekt.TooManyFunctions")
|
||||
class OutcomingVoiceMessageViewHolder(outcomingView: View) :
|
||||
MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(outcomingView),
|
||||
AdjustableMessageHolderInterface {
|
||||
|
||||
override val binding: ItemCustomOutcomingVoiceMessageBinding = ItemCustomOutcomingVoiceMessageBinding.bind(itemView)
|
||||
|
||||
@JvmField
|
||||
@Inject
|
||||
var context: Context? = null
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var dateUtils: DateUtils
|
||||
|
||||
@Inject
|
||||
lateinit var appPreferences: AppPreferences
|
||||
|
||||
lateinit var message: ChatMessage
|
||||
|
||||
lateinit var handler: Handler
|
||||
|
||||
lateinit var voiceMessageInterface: VoiceMessageInterface
|
||||
lateinit var commonMessageInterface: CommonMessageInterface
|
||||
private var isBound = false
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
if (isBound) {
|
||||
handleIsPlayingVoiceMessageState(message)
|
||||
return
|
||||
}
|
||||
|
||||
this.message = message
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
|
||||
|
||||
val filename = message.selectedIndividualHashMap!!["name"]
|
||||
val retrieved = appPreferences.getWaveFormFromFile(filename)
|
||||
if (retrieved.isNotEmpty() &&
|
||||
message.voiceMessageFloatArray == null ||
|
||||
message.voiceMessageFloatArray?.isEmpty() == true
|
||||
) {
|
||||
message.voiceMessageFloatArray = retrieved.toFloatArray()
|
||||
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
|
||||
}
|
||||
|
||||
binding.seekbar.max = MAX
|
||||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
|
||||
colorizeMessageBubble(message)
|
||||
|
||||
itemView.isSelected = false
|
||||
|
||||
// parent message handling
|
||||
setParentMessageDataOnMessageItem(message)
|
||||
|
||||
updateDownloadState(message)
|
||||
viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar)
|
||||
viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT)
|
||||
|
||||
showVoiceMessageDuration(message)
|
||||
|
||||
handleIsDownloadingVoiceMessageState(message)
|
||||
|
||||
handleResetVoiceMessageState(message)
|
||||
|
||||
binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
// unused atm
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar) {
|
||||
// unused atm
|
||||
}
|
||||
|
||||
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
|
||||
if (fromUser) {
|
||||
voiceMessageInterface.updateMediaPlayerProgressBySlider(message, progress)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setReadStatus(message.readStatus)
|
||||
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
(voiceMessageInterface as ChatActivity).chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed ->
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.playbackSpeedControlBtn.setSpeed(speed)
|
||||
}
|
||||
}.collect()
|
||||
}
|
||||
|
||||
binding.playbackSpeedControlBtn.setSpeed(appPreferences.getPreferredPlayback(message.actorId))
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
::longClickOnReaction,
|
||||
binding.reactions,
|
||||
binding.messageTime.context,
|
||||
true,
|
||||
viewThemeUtils
|
||||
)
|
||||
isBound = true
|
||||
}
|
||||
|
||||
private fun setReadStatus(readStatus: Enum<ReadStatus>) {
|
||||
val readStatusDrawableInt = when (readStatus) {
|
||||
ReadStatus.READ -> R.drawable.ic_check_all
|
||||
ReadStatus.SENT -> R.drawable.ic_check
|
||||
else -> null
|
||||
}
|
||||
|
||||
val readStatusContentDescriptionString = when (readStatus) {
|
||||
ReadStatus.READ -> context?.resources?.getString(R.string.nc_message_read)
|
||||
ReadStatus.SENT -> context?.resources?.getString(R.string.nc_message_sent)
|
||||
else -> null
|
||||
}
|
||||
|
||||
readStatusDrawableInt?.let { drawableInt ->
|
||||
AppCompatResources.getDrawable(context!!, drawableInt)?.let {
|
||||
binding.checkMark.setImageDrawable(it)
|
||||
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark)
|
||||
}
|
||||
}
|
||||
|
||||
binding.checkMark.contentDescription = readStatusContentDescriptionString
|
||||
}
|
||||
|
||||
private fun longClickOnReaction(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.onLongClickReactions(chatMessage)
|
||||
}
|
||||
|
||||
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
|
||||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
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.voiceMessagePlayedSeconds = 0
|
||||
showVoiceMessageDuration(message)
|
||||
message.resetVoiceMessage = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun showVoiceMessageDuration(message: ChatMessage) {
|
||||
if (message.voiceMessageDuration > 0) {
|
||||
binding.voiceMessageDuration.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.voiceMessageDuration.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIsDownloadingVoiceMessageState(message: ChatMessage) {
|
||||
if (message.isDownloadingVoiceMessage) {
|
||||
showVoiceMessageLoading()
|
||||
} else {
|
||||
if (message.voiceMessageFloatArray == null || message.voiceMessageFloatArray!!.isEmpty()) {
|
||||
binding.seekbar.setWaveData(FloatArray(0))
|
||||
} else {
|
||||
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
|
||||
}
|
||||
binding.progressBar.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIsPlayingVoiceMessageState(message: ChatMessage) {
|
||||
colorizeMessageBubble(message)
|
||||
if (message.isPlayingVoiceMessage) {
|
||||
showPlayButton()
|
||||
binding.playPauseBtn.icon = ContextCompat.getDrawable(
|
||||
context!!,
|
||||
R.drawable.ic_baseline_pause_voice_message_24
|
||||
)
|
||||
|
||||
val d = message.voiceMessageDuration.toLong()
|
||||
val t = message.voiceMessagePlayedSeconds.toLong()
|
||||
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
|
||||
binding.voiceMessageDuration.visibility = View.VISIBLE
|
||||
binding.seekbar.progress = message.voiceMessageSeekbarProgress
|
||||
} else {
|
||||
showVoiceMessageDuration(message)
|
||||
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.selectedIndividualHashMap!!["id"]
|
||||
val workers = WorkManager.getInstance(context!!).getWorkInfosByTag(fileId!!)
|
||||
|
||||
try {
|
||||
for (workInfo in workers.get()) {
|
||||
if (workInfo.state == WorkInfo.State.RUNNING || workInfo.state == WorkInfo.State.ENQUEUED) {
|
||||
showVoiceMessageLoading()
|
||||
WorkManager.getInstance(context!!).getWorkInfoByIdLiveData(workInfo.id)
|
||||
.observeForever { info: WorkInfo? ->
|
||||
updateDownloadState(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: ExecutionException) {
|
||||
Log.e(TAG, "Error when checking if worker already exists", e)
|
||||
} catch (e: InterruptedException) {
|
||||
Log.e(TAG, "Error when checking if worker already exists", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDownloadState(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 -> {
|
||||
Log.d(TAG, "WorkInfo.State unused in ViewHolder")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPlayButton() {
|
||||
binding.playPauseBtn.visibility = View.VISIBLE
|
||||
binding.progressBar.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun showVoiceMessageLoading() {
|
||||
binding.playPauseBtn.visibility = View.GONE
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val urlForChatting = ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
chatActivity.conversationUser?.baseUrl,
|
||||
chatActivity.roomToken
|
||||
)
|
||||
|
||||
val parentChatMessage = withContext(Dispatchers.IO) {
|
||||
chatActivity.chatViewModel.getMessageById(
|
||||
urlForChatting,
|
||||
chatActivity.currentConversation!!,
|
||||
message.parentMessageId!!
|
||||
).first()
|
||||
}
|
||||
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 = messageUtils
|
||||
.enrichChatReplyMessageText(
|
||||
binding.messageQuote.quotedMessage.context,
|
||||
parentChatMessage,
|
||||
false,
|
||||
viewThemeUtils
|
||||
)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorizeMessageBubble(message: ChatMessage) {
|
||||
viewThemeUtils.talk.themeOutgoingMessageBubble(
|
||||
bubble,
|
||||
message.isGrouped,
|
||||
message.isDeleted,
|
||||
message.wasPlayedVoiceMessage
|
||||
)
|
||||
}
|
||||
|
||||
fun assignVoiceMessageInterface(voiceMessageInterface: VoiceMessageInterface) {
|
||||
this.voiceMessageInterface = voiceMessageInterface
|
||||
}
|
||||
|
||||
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
|
||||
this.commonMessageInterface = commonMessageInterface
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VoiceOutMessageView"
|
||||
private const val SEEKBAR_START: Int = 0
|
||||
private const val MAX = 100
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
|
||||
interface PreviewMessageInterface {
|
||||
fun onPreviewMessageLongClick(chatMessage: ChatMessage)
|
||||
}
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2021-2022 Tim Krüger <t@timkrueger.me>
|
||||
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Handler
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.emoji2.widget.EmojiTextView
|
||||
import autodagger.AutoInjector
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.nextcloud.android.common.ui.theme.utils.ColorRole
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.ItemThreadTitleBinding
|
||||
import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding
|
||||
import com.nextcloud.talk.extensions.loadChangelogBotAvatar
|
||||
import com.nextcloud.talk.extensions.loadFederatedUserAvatar
|
||||
import com.nextcloud.talk.filebrowser.models.BrowserFile
|
||||
import com.nextcloud.talk.filebrowser.webdav.ReadFilesystemOperation
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.DisplayUtils
|
||||
import com.nextcloud.talk.utils.DrawableUtils.getDrawableResourceIdForMimeType
|
||||
import com.nextcloud.talk.utils.FileViewerUtils
|
||||
import com.nextcloud.talk.utils.FileViewerUtils.ProgressUi
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.stfalcon.chatkit.messages.MessageHolders.IncomingImageMessageViewHolder
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.SingleObserver
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
abstract class PreviewMessageViewHolder(itemView: View?, payload: Any?) :
|
||||
IncomingImageMessageViewHolder<ChatMessage>(itemView, payload) {
|
||||
@JvmField
|
||||
@Inject
|
||||
var context: Context? = null
|
||||
|
||||
@JvmField
|
||||
@Inject
|
||||
var viewThemeUtils: ViewThemeUtils? = null
|
||||
|
||||
@Inject
|
||||
lateinit var dateUtils: DateUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var userManager: UserManager
|
||||
|
||||
@JvmField
|
||||
@Inject
|
||||
var okHttpClient: OkHttpClient? = null
|
||||
open var progressBar: ProgressBar? = null
|
||||
open var reactionsBinding: ReactionsInsideMessageBinding? = null
|
||||
open var threadsBinding: ItemThreadTitleBinding? = null
|
||||
var fileViewerUtils: FileViewerUtils? = null
|
||||
var clickView: View? = null
|
||||
|
||||
lateinit var commonMessageInterface: CommonMessageInterface
|
||||
var previewMessageInterface: PreviewMessageInterface? = null
|
||||
|
||||
private var placeholder: Drawable? = null
|
||||
|
||||
init {
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
@Suppress("NestedBlockDepth", "ComplexMethod", "LongMethod")
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
image.minimumHeight = DisplayUtils.convertDpToPixel(MIN_IMAGE_HEIGHT, context!!).toInt()
|
||||
|
||||
time.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
|
||||
viewThemeUtils!!.platform.colorCircularProgressBar(progressBar!!, ColorRole.PRIMARY)
|
||||
clickView = image
|
||||
messageText.visibility = View.VISIBLE
|
||||
if (message.getCalculateMessageType() === ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE) {
|
||||
fileViewerUtils = FileViewerUtils(context!!, message.activeUser!!)
|
||||
val fileName = message.selectedIndividualHashMap!![KEY_NAME]
|
||||
|
||||
messageText.text = fileName
|
||||
|
||||
if (message.activeUser != null &&
|
||||
message.activeUser!!.username != null &&
|
||||
message.activeUser!!.baseUrl != null
|
||||
) {
|
||||
clickView!!.setOnClickListener { v: View? ->
|
||||
fileViewerUtils!!.openFile(
|
||||
message,
|
||||
ProgressUi(progressBar, messageText, image)
|
||||
)
|
||||
}
|
||||
clickView!!.setOnLongClickListener {
|
||||
previewMessageInterface!!.onPreviewMessageLongClick(message)
|
||||
true
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "failed to set click listener because activeUser, username or baseUrl were null")
|
||||
}
|
||||
fileViewerUtils!!.resumeToUpdateViewsByProgress(
|
||||
message.selectedIndividualHashMap!![KEY_NAME]!!,
|
||||
message.selectedIndividualHashMap!![KEY_ID]!!,
|
||||
message.selectedIndividualHashMap!![KEY_MIMETYPE],
|
||||
message.openWhenDownloaded,
|
||||
ProgressUi(progressBar, messageText, image)
|
||||
)
|
||||
} else if (message.getCalculateMessageType() === ChatMessage.MessageType.SINGLE_LINK_GIPHY_MESSAGE) {
|
||||
messageText.text = "GIPHY"
|
||||
DisplayUtils.setClickableString("GIPHY", "https://giphy.com", messageText)
|
||||
} else if (message.getCalculateMessageType() === ChatMessage.MessageType.SINGLE_LINK_TENOR_MESSAGE) {
|
||||
messageText.text = "Tenor"
|
||||
DisplayUtils.setClickableString("Tenor", "https://tenor.com", messageText)
|
||||
} else {
|
||||
if (message.messageType == ChatMessage.MessageType.SINGLE_LINK_IMAGE_MESSAGE.name) {
|
||||
clickView!!.setOnClickListener {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, message.imageUrl!!.toUri())
|
||||
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context!!.startActivity(browserIntent)
|
||||
}
|
||||
} else {
|
||||
clickView!!.setOnClickListener(null)
|
||||
}
|
||||
messageText.text = ""
|
||||
}
|
||||
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
|
||||
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
Thread().showThreadPreview(
|
||||
chatActivity,
|
||||
message,
|
||||
threadBinding = threadsBinding!!,
|
||||
reactionsBinding = reactionsBinding!!,
|
||||
openThread = { openThread(message) }
|
||||
)
|
||||
|
||||
val paddingSide = DisplayUtils.convertDpToPixel(HORIZONTAL_REACTION_PADDING, context!!).toInt()
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
::longClickOnReaction,
|
||||
reactionsBinding!!,
|
||||
messageText.context,
|
||||
true,
|
||||
viewThemeUtils!!,
|
||||
hasBubbleBackground(message)
|
||||
)
|
||||
reactionsBinding!!.reactionsEmojiWrapper.setPadding(paddingSide, 0, paddingSide, 0)
|
||||
|
||||
if (userAvatar != null) {
|
||||
if (message.isGrouped || message.isOneToOneConversation) {
|
||||
if (message.isOneToOneConversation) {
|
||||
userAvatar.visibility = View.GONE
|
||||
} else {
|
||||
userAvatar.visibility = View.INVISIBLE
|
||||
}
|
||||
} else {
|
||||
userAvatar.visibility = View.VISIBLE
|
||||
userAvatar.setOnClickListener { v: View ->
|
||||
if (payload is MessagePayload) {
|
||||
(payload as MessagePayload).profileBottomSheet.showFor(
|
||||
message,
|
||||
v.context
|
||||
)
|
||||
}
|
||||
}
|
||||
if (ACTOR_TYPE_BOTS == message.actorType && ACTOR_ID_CHANGELOG == message.actorId) {
|
||||
userAvatar.loadChangelogBotAvatar()
|
||||
} else if (message.actorType == "federated_users") {
|
||||
userAvatar.loadFederatedUserAvatar(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messageCaption.setOnClickListener(null)
|
||||
messageCaption.setOnLongClickListener {
|
||||
previewMessageInterface!!.onPreviewMessageLongClick(message)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun longClickOnReaction(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.onLongClickReactions(chatMessage)
|
||||
}
|
||||
|
||||
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
|
||||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
override fun getPayloadForImageLoader(message: ChatMessage?): Any? {
|
||||
if (message!!.selectedIndividualHashMap!!.containsKey(KEY_CONTACT_NAME)) {
|
||||
previewContainer.visibility = View.GONE
|
||||
previewContactContainer.visibility = View.VISIBLE
|
||||
previewContactName.text = message.selectedIndividualHashMap!![KEY_CONTACT_NAME]
|
||||
progressBar = previewContactProgressBar
|
||||
messageText.visibility = View.INVISIBLE
|
||||
clickView = previewContactContainer
|
||||
viewThemeUtils!!.talk.colorContactChatItemBackground(previewContactContainer)
|
||||
viewThemeUtils!!.talk.colorContactChatItemName(previewContactName)
|
||||
viewThemeUtils!!.platform.colorCircularProgressBar(
|
||||
previewContactProgressBar!!,
|
||||
ColorRole.ON_PRIMARY_CONTAINER
|
||||
)
|
||||
|
||||
if (message.selectedIndividualHashMap!!.containsKey(KEY_CONTACT_PHOTO)) {
|
||||
image = previewContactPhoto
|
||||
placeholder = getDrawableFromContactDetails(
|
||||
context,
|
||||
message.selectedIndividualHashMap!![KEY_CONTACT_PHOTO]
|
||||
)
|
||||
} else {
|
||||
image = previewContactPhoto
|
||||
image.setImageDrawable(ContextCompat.getDrawable(context!!, R.drawable.ic_mimetype_text_vcard))
|
||||
}
|
||||
} else {
|
||||
previewContainer.visibility = View.VISIBLE
|
||||
previewContactContainer.visibility = View.GONE
|
||||
}
|
||||
|
||||
if (message.selectedIndividualHashMap!!.containsKey(KEY_MIMETYPE)) {
|
||||
val mimetype = message.selectedIndividualHashMap!![KEY_MIMETYPE]
|
||||
val drawableResourceId = getDrawableResourceIdForMimeType(mimetype)
|
||||
var drawable = ContextCompat.getDrawable(context!!, drawableResourceId)
|
||||
if (drawable != null &&
|
||||
(
|
||||
drawableResourceId == R.drawable.ic_mimetype_folder ||
|
||||
drawableResourceId == R.drawable.ic_mimetype_package_x_generic
|
||||
)
|
||||
) {
|
||||
drawable = viewThemeUtils?.platform?.tintDrawable(context!!, drawable)
|
||||
}
|
||||
placeholder = drawable
|
||||
} else {
|
||||
fetchFileInformation(
|
||||
"/" + message.selectedIndividualHashMap!![KEY_PATH],
|
||||
message.activeUser
|
||||
)
|
||||
}
|
||||
|
||||
return placeholder
|
||||
}
|
||||
|
||||
private fun getDrawableFromContactDetails(context: Context?, base64: String?): Drawable? {
|
||||
var drawable: Drawable? = null
|
||||
if (base64 != "") {
|
||||
val inputStream = ByteArrayInputStream(
|
||||
Base64.decode(base64!!.toByteArray(), Base64.DEFAULT)
|
||||
)
|
||||
drawable = Drawable.createFromResourceStream(
|
||||
context!!.resources,
|
||||
null,
|
||||
inputStream,
|
||||
null,
|
||||
null
|
||||
)
|
||||
try {
|
||||
inputStream.close()
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "failed to close stream in getDrawableFromContactDetails", e)
|
||||
}
|
||||
}
|
||||
if (drawable == null) {
|
||||
drawable = ContextCompat.getDrawable(context!!, R.drawable.ic_mimetype_text_vcard)
|
||||
}
|
||||
return drawable
|
||||
}
|
||||
|
||||
private fun fetchFileInformation(url: String, activeUser: User?) {
|
||||
Single.fromCallable { ReadFilesystemOperation(okHttpClient, activeUser, url, 0) }
|
||||
.observeOn(Schedulers.io())
|
||||
.subscribe(object : SingleObserver<ReadFilesystemOperation> {
|
||||
override fun onSubscribe(d: Disposable) {
|
||||
// unused atm
|
||||
}
|
||||
|
||||
override fun onSuccess(readFilesystemOperation: ReadFilesystemOperation) {
|
||||
val davResponse = readFilesystemOperation.readRemotePath()
|
||||
if (davResponse.data != null) {
|
||||
val browserFileList = davResponse.data as List<BrowserFile>
|
||||
if (browserFileList.isNotEmpty()) {
|
||||
Handler(context!!.mainLooper).post {
|
||||
val resourceId = getDrawableResourceIdForMimeType(browserFileList[0].mimeType)
|
||||
placeholder = ContextCompat.getDrawable(context!!, resourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
Log.e(TAG, "Error reading file information", e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
|
||||
this.commonMessageInterface = commonMessageInterface
|
||||
}
|
||||
|
||||
fun assignPreviewMessageInterface(previewMessageInterface: PreviewMessageInterface?) {
|
||||
this.previewMessageInterface = previewMessageInterface
|
||||
}
|
||||
|
||||
fun hasBubbleBackground(message: ChatMessage): Boolean = !message.isVoiceMessage && message.message != "{file}"
|
||||
|
||||
abstract val messageText: EmojiTextView
|
||||
abstract val messageCaption: EmojiTextView
|
||||
abstract val previewContainer: View
|
||||
abstract val previewContactContainer: MaterialCardView
|
||||
abstract val previewContactPhoto: ImageView
|
||||
abstract val previewContactName: EmojiTextView
|
||||
abstract val previewContactProgressBar: ProgressBar?
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PreviewMsgViewHolder"
|
||||
const val KEY_CONTACT_NAME = "contact-name"
|
||||
const val KEY_CONTACT_PHOTO = "contact-photo"
|
||||
const val KEY_MIMETYPE = "mimetype"
|
||||
const val KEY_ID = "id"
|
||||
const val KEY_PATH = "path"
|
||||
const val ACTOR_TYPE_BOTS = "bots"
|
||||
const val ACTOR_ID_CHANGELOG = "changelog"
|
||||
const val KEY_NAME = "name"
|
||||
const val MIN_IMAGE_HEIGHT = 100F
|
||||
const val HORIZONTAL_REACTION_PADDING = 8.0F
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.utils.DisplayUtils
|
||||
import com.vanniktech.emoji.EmojiTextView
|
||||
|
||||
class Reaction {
|
||||
|
||||
fun showReactions(
|
||||
message: ChatMessage,
|
||||
clickOnReaction: (message: ChatMessage, emoji: String) -> Unit,
|
||||
longClickOnReaction: (message: ChatMessage) -> Unit,
|
||||
binding: ReactionsInsideMessageBinding,
|
||||
context: Context,
|
||||
isOutgoingMessage: Boolean,
|
||||
viewThemeUtils: ViewThemeUtils,
|
||||
isBubbled: Boolean = true
|
||||
) {
|
||||
binding.reactionsEmojiWrapper.removeAllViews()
|
||||
|
||||
if (message.reactions != null && message.reactions!!.isNotEmpty()) {
|
||||
binding.reactionsEmojiWrapper.visibility = View.VISIBLE
|
||||
|
||||
binding.reactionsEmojiWrapper.setOnLongClickListener {
|
||||
longClickOnReaction(message)
|
||||
true
|
||||
}
|
||||
|
||||
val amountParams = getAmountLayoutParams(context)
|
||||
val wrapperParams = getWrapperLayoutParams(context)
|
||||
|
||||
val paddingSide = DisplayUtils.convertDpToPixel(EMOJI_AND_AMOUNT_PADDING_SIDE, context).toInt()
|
||||
val paddingTop = DisplayUtils.convertDpToPixel(WRAPPER_PADDING_TOP, context).toInt()
|
||||
val paddingBottom = DisplayUtils.convertDpToPixel(WRAPPER_PADDING_BOTTOM, context).toInt()
|
||||
|
||||
for ((emoji, amount) in message.reactions!!) {
|
||||
val isSelfReaction = message.reactionsSelf != null &&
|
||||
message.reactionsSelf!!.isNotEmpty() &&
|
||||
message.reactionsSelf!!.contains(emoji)
|
||||
val textColor = viewThemeUtils.talk.getTextColor(isOutgoingMessage, isSelfReaction, binding)
|
||||
val emojiWithAmountWrapper = getEmojiWithAmountWrapperLayout(
|
||||
binding.reactionsEmojiWrapper.context,
|
||||
emoji,
|
||||
amount,
|
||||
EmojiWithAmountWrapperLayoutInfo(
|
||||
textColor,
|
||||
amountParams,
|
||||
wrapperParams,
|
||||
paddingSide,
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
viewThemeUtils,
|
||||
isOutgoingMessage,
|
||||
isSelfReaction
|
||||
),
|
||||
isBubbled
|
||||
)
|
||||
|
||||
emojiWithAmountWrapper.setOnClickListener {
|
||||
clickOnReaction(message, emoji)
|
||||
}
|
||||
emojiWithAmountWrapper.setOnLongClickListener {
|
||||
longClickOnReaction(message)
|
||||
false
|
||||
}
|
||||
|
||||
binding.reactionsEmojiWrapper.addView(emojiWithAmountWrapper)
|
||||
}
|
||||
} else {
|
||||
binding.reactionsEmojiWrapper.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEmojiWithAmountWrapperLayout(
|
||||
context: Context,
|
||||
emoji: String,
|
||||
amount: Int,
|
||||
layoutInfo: EmojiWithAmountWrapperLayoutInfo,
|
||||
isBubbled: Boolean
|
||||
): LinearLayout {
|
||||
val emojiWithAmountWrapper = LinearLayout(context)
|
||||
emojiWithAmountWrapper.orientation = LinearLayout.HORIZONTAL
|
||||
|
||||
emojiWithAmountWrapper.addView(getEmojiTextView(context, emoji))
|
||||
emojiWithAmountWrapper.addView(getReactionCount(context, layoutInfo.textColor, amount, layoutInfo.amountParams))
|
||||
emojiWithAmountWrapper.layoutParams = layoutInfo.wrapperParams
|
||||
|
||||
if (layoutInfo.isSelfReaction) {
|
||||
layoutInfo.viewThemeUtils.talk.setCheckedBackground(
|
||||
emojiWithAmountWrapper,
|
||||
layoutInfo.isOutgoingMessage,
|
||||
isBubbled
|
||||
)
|
||||
|
||||
emojiWithAmountWrapper.setPaddingRelative(
|
||||
layoutInfo.paddingSide,
|
||||
layoutInfo.paddingTop,
|
||||
layoutInfo.paddingSide,
|
||||
layoutInfo.paddingBottom
|
||||
)
|
||||
} else {
|
||||
emojiWithAmountWrapper.setPaddingRelative(
|
||||
0,
|
||||
layoutInfo.paddingTop,
|
||||
layoutInfo.paddingSide,
|
||||
layoutInfo.paddingBottom
|
||||
)
|
||||
}
|
||||
return emojiWithAmountWrapper
|
||||
}
|
||||
|
||||
private fun getEmojiTextView(context: Context, emoji: String): EmojiTextView {
|
||||
val reactionEmoji = EmojiTextView(context)
|
||||
reactionEmoji.text = emoji
|
||||
return reactionEmoji
|
||||
}
|
||||
|
||||
private fun getReactionCount(
|
||||
context: Context,
|
||||
textColor: Int,
|
||||
amount: Int,
|
||||
amountParams: LinearLayout.LayoutParams
|
||||
): TextView {
|
||||
val reactionAmount = TextView(context)
|
||||
reactionAmount.setTextColor(textColor)
|
||||
reactionAmount.text = amount.toString()
|
||||
reactionAmount.layoutParams = amountParams
|
||||
return reactionAmount
|
||||
}
|
||||
|
||||
private fun getWrapperLayoutParams(context: Context): LinearLayout.LayoutParams {
|
||||
val wrapperParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
wrapperParams.marginEnd = DisplayUtils.convertDpToPixel(EMOJI_END_MARGIN, context).toInt()
|
||||
return wrapperParams
|
||||
}
|
||||
|
||||
private fun getAmountLayoutParams(context: Context): LinearLayout.LayoutParams {
|
||||
val amountParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
amountParams.marginStart = DisplayUtils.convertDpToPixel(AMOUNT_START_MARGIN, context).toInt()
|
||||
return amountParams
|
||||
}
|
||||
|
||||
private data class EmojiWithAmountWrapperLayoutInfo(
|
||||
val textColor: Int,
|
||||
val amountParams: LinearLayout.LayoutParams,
|
||||
val wrapperParams: LinearLayout.LayoutParams,
|
||||
val paddingSide: Int,
|
||||
val paddingTop: Int,
|
||||
val paddingBottom: Int,
|
||||
val viewThemeUtils: ViewThemeUtils,
|
||||
val isOutgoingMessage: Boolean,
|
||||
val isSelfReaction: Boolean
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val AMOUNT_START_MARGIN: Float = 2F
|
||||
const val EMOJI_END_MARGIN: Float = 6F
|
||||
const val EMOJI_AND_AMOUNT_PADDING_SIDE: Float = 4F
|
||||
const val WRAPPER_PADDING_TOP: Float = 2F
|
||||
const val WRAPPER_PADDING_BOTTOM: Float = 3F
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
|
||||
interface SystemMessageInterface {
|
||||
fun expandSystemMessage(chatMessage: ChatMessage)
|
||||
fun collapseSystemMessages()
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.TextPaint
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.text.style.ClickableSpan
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.ViewCompat
|
||||
import autodagger.AutoInjector
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ItemSystemMessageBinding
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.DisplayUtils
|
||||
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import com.stfalcon.chatkit.messages.MessageHolders
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class SystemMessageViewHolder(itemView: View) :
|
||||
MessageHolders
|
||||
.IncomingTextMessageViewHolder<ChatMessage>(itemView) {
|
||||
|
||||
private val binding: ItemSystemMessageBinding = ItemSystemMessageBinding.bind(itemView)
|
||||
|
||||
@Inject
|
||||
lateinit var currentUserProvider: CurrentUserProviderNew
|
||||
|
||||
@JvmField
|
||||
@Inject
|
||||
var appPreferences: AppPreferences? = null
|
||||
|
||||
@JvmField
|
||||
@Inject
|
||||
var context: Context? = null
|
||||
|
||||
@JvmField
|
||||
@Inject
|
||||
var dateUtils: DateUtils? = null
|
||||
protected var background: ViewGroup
|
||||
|
||||
lateinit var systemMessageInterface: SystemMessageInterface
|
||||
|
||||
init {
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
background = itemView.findViewById(R.id.container)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBind(message: ChatMessage) {
|
||||
super.onBind(message)
|
||||
val user = currentUserProvider.currentUser.blockingGet()
|
||||
val resources = itemView.resources
|
||||
val pressedColor: Int = resources.getColor(R.color.bg_message_list_incoming_bubble)
|
||||
val mentionColor: Int = resources.getColor(R.color.textColorMaxContrast)
|
||||
val bubbleDrawable = DisplayUtils.getMessageSelector(
|
||||
resources.getColor(R.color.transparent),
|
||||
resources.getColor(R.color.transparent),
|
||||
pressedColor,
|
||||
R.drawable.shape_grouped_incoming_message
|
||||
)
|
||||
ViewCompat.setBackground(background, bubbleDrawable)
|
||||
binding.messageText.setTextSize(
|
||||
TypedValue.COMPLEX_UNIT_PX,
|
||||
resources.getDimension(R.dimen.chat_system_message_text_size)
|
||||
)
|
||||
var messageString: Spannable = SpannableString(message.text)
|
||||
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
|
||||
for (key in message.messageParameters!!.keys) {
|
||||
val individualMap: Map<String?, String?>? = message.messageParameters!![key]
|
||||
if (individualMap != null && individualMap.containsKey("name")) {
|
||||
var searchText: String? = if ("user" == individualMap["type"] ||
|
||||
"guest" == individualMap["type"] ||
|
||||
"call" == individualMap["type"]
|
||||
) {
|
||||
"@" + individualMap["name"]
|
||||
} else {
|
||||
individualMap["name"]
|
||||
}
|
||||
messageString =
|
||||
DisplayUtils.searchAndColor(
|
||||
messageString,
|
||||
searchText!!,
|
||||
mentionColor,
|
||||
resources.getDimensionPixelSize(R.dimen.chat_system_message_text_size)
|
||||
)
|
||||
if (individualMap["link"] != null) {
|
||||
val displayName = individualMap["name"] ?: ""
|
||||
val link = (user.baseUrl + individualMap["link"])
|
||||
val newStartIndex = messageString.indexOf(displayName)
|
||||
if (newStartIndex != -1) {
|
||||
val clickableSpan = object : ClickableSpan() {
|
||||
override fun onClick(widget: View) {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, link.toUri())
|
||||
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context?.startActivity(browserIntent)
|
||||
}
|
||||
|
||||
override fun updateDrawState(ds: TextPaint) {
|
||||
super.updateDrawState(ds)
|
||||
ds.color = mentionColor
|
||||
ds.isUnderlineText = false
|
||||
}
|
||||
}
|
||||
|
||||
messageString.setSpan(
|
||||
clickableSpan,
|
||||
newStartIndex,
|
||||
newStartIndex + displayName.length,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.systemMessageLayout.visibility = View.VISIBLE
|
||||
binding.similarMessagesHint.visibility = View.GONE
|
||||
if (message.expandableParent) {
|
||||
processExpandableParent(message, messageString)
|
||||
} else if (message.hiddenByCollapse) {
|
||||
binding.systemMessageLayout.visibility = View.GONE
|
||||
} else {
|
||||
binding.expandCollapseIcon.visibility = View.GONE
|
||||
binding.messageText.text = messageString
|
||||
binding.expandCollapseIcon.setImageDrawable(null)
|
||||
binding.systemMessageLayout.setOnClickListener(null)
|
||||
}
|
||||
|
||||
if (!message.expandableParent && message.lastItemOfExpandableGroup != 0) {
|
||||
binding.systemMessageLayout.setOnClickListener { systemMessageInterface.collapseSystemMessages() }
|
||||
binding.messageText.setOnClickListener { systemMessageInterface.collapseSystemMessages() }
|
||||
}
|
||||
|
||||
binding.messageTime.text = dateUtils!!.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private fun processExpandableParent(message: ChatMessage, messageString: Spannable) {
|
||||
binding.expandCollapseIcon.visibility = View.VISIBLE
|
||||
|
||||
if (!message.isExpanded) {
|
||||
val similarMessages = sharedApplication!!.resources.getQuantityString(
|
||||
R.plurals.see_similar_system_messages,
|
||||
message.expandableChildrenAmount,
|
||||
message.expandableChildrenAmount
|
||||
)
|
||||
|
||||
binding.messageText.text = messageString
|
||||
binding.messageText.movementMethod = LinkMovementMethod.getInstance()
|
||||
binding.similarMessagesHint.visibility = View.VISIBLE
|
||||
binding.similarMessagesHint.text = similarMessages
|
||||
|
||||
binding.expandCollapseIcon.setImageDrawable(
|
||||
ContextCompat.getDrawable(context!!, R.drawable.baseline_unfold_more_24)
|
||||
)
|
||||
binding.systemMessageLayout.setOnClickListener { systemMessageInterface.expandSystemMessage(message) }
|
||||
binding.messageText.setOnClickListener { systemMessageInterface.expandSystemMessage(message) }
|
||||
} else {
|
||||
binding.messageText.text = messageString
|
||||
binding.similarMessagesHint.visibility = View.GONE
|
||||
binding.similarMessagesHint.text = ""
|
||||
|
||||
binding.expandCollapseIcon.setImageDrawable(
|
||||
ContextCompat.getDrawable(context!!, R.drawable.baseline_unfold_less_24)
|
||||
)
|
||||
binding.systemMessageLayout.setOnClickListener { systemMessageInterface.collapseSystemMessages() }
|
||||
binding.messageText.setOnClickListener { systemMessageInterface.collapseSystemMessages() }
|
||||
}
|
||||
}
|
||||
|
||||
fun assignSystemMessageInterface(systemMessageInterface: SystemMessageInterface) {
|
||||
this.systemMessageInterface = systemMessageInterface
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
|
||||
* SPDX-FileCopyrightText: 2020 Tobias Kaminsky <tobias.kaminsky@nextcloud.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages;
|
||||
|
||||
import com.nextcloud.talk.chat.ChatActivity;
|
||||
import com.stfalcon.chatkit.commons.ImageLoader;
|
||||
import com.stfalcon.chatkit.commons.ViewHolder;
|
||||
import com.stfalcon.chatkit.commons.models.IMessage;
|
||||
import com.stfalcon.chatkit.messages.MessageHolders;
|
||||
import com.stfalcon.chatkit.messages.MessagesListAdapter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class TalkMessagesListAdapter<M extends IMessage> extends MessagesListAdapter<M> {
|
||||
private final ChatActivity chatActivity;
|
||||
|
||||
public TalkMessagesListAdapter(
|
||||
String senderId,
|
||||
MessageHolders holders,
|
||||
ImageLoader imageLoader,
|
||||
ChatActivity chatActivity) {
|
||||
super(senderId, holders, imageLoader);
|
||||
this.chatActivity = chatActivity;
|
||||
}
|
||||
|
||||
public List<MessagesListAdapter.Wrapper> getItems() {
|
||||
return items;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
|
||||
if (holder instanceof IncomingTextMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
} else if (holder instanceof OutcomingTextMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
|
||||
|
||||
} else if (holder instanceof IncomingLocationMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
} else if (holder instanceof OutcomingLocationMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
|
||||
|
||||
} else if (holder instanceof IncomingLinkPreviewMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
} else if (holder instanceof OutcomingLinkPreviewMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
|
||||
|
||||
} else if (holder instanceof IncomingVoiceMessageViewHolder holderInstance) {
|
||||
holderInstance.assignVoiceMessageInterface(chatActivity);
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
} else if (holder instanceof OutcomingVoiceMessageViewHolder holderInstance) {
|
||||
holderInstance.assignVoiceMessageInterface(chatActivity);
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
|
||||
|
||||
} else if (holder instanceof PreviewMessageViewHolder holderInstance) {
|
||||
holderInstance.assignPreviewMessageInterface(chatActivity);
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
|
||||
} else if (holder instanceof SystemMessageViewHolder holderInstance) {
|
||||
holderInstance.assignSystemMessageInterface(chatActivity);
|
||||
|
||||
} else if (holder instanceof IncomingDeckCardViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
} else if (holder instanceof OutcomingDeckCardViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
|
||||
|
||||
} else if (holder instanceof IncomingPollMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
} else if (holder instanceof OutcomingPollMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
|
||||
}
|
||||
|
||||
super.onBindViewHolder(holder, position);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.view.View
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ItemThreadTitleBinding
|
||||
|
||||
class Thread {
|
||||
|
||||
fun showThreadPreview(
|
||||
chatActivity: ChatActivity,
|
||||
message: ChatMessage,
|
||||
threadBinding: ItemThreadTitleBinding,
|
||||
reactionsBinding: ReactionsInsideMessageBinding,
|
||||
openThread: (message: ChatMessage) -> Unit
|
||||
) {
|
||||
val isFirstMessageOfThreadInNormalChat = chatActivity.conversationThreadId == null && message.isThread
|
||||
if (isFirstMessageOfThreadInNormalChat) {
|
||||
threadBinding.threadTitleLayout.visibility = View.VISIBLE
|
||||
|
||||
threadBinding.threadTitleLayout.findViewById<androidx.emoji2.widget.EmojiTextView>(R.id.threadTitle).text =
|
||||
message.threadTitle
|
||||
|
||||
reactionsBinding.threadButton.visibility = View.VISIBLE
|
||||
|
||||
reactionsBinding.threadButton.setContent {
|
||||
ThreadButtonComposable(
|
||||
message.threadReplies ?: 0,
|
||||
onButtonClick = { openThread(message) }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
threadBinding.threadTitleLayout.visibility = View.GONE
|
||||
reactionsBinding.threadButton.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nextcloud.talk.R
|
||||
|
||||
@Composable
|
||||
fun ThreadButtonComposable(replyAmount: Int = 0, onButtonClick: () -> Unit = {}) {
|
||||
val replyAmountText = if (replyAmount == 0) {
|
||||
stringResource(R.string.thread_reply)
|
||||
} else {
|
||||
pluralStringResource(
|
||||
R.plurals.thread_replies,
|
||||
replyAmount,
|
||||
replyAmount
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onButtonClick,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.height(24.dp),
|
||||
shape = RoundedCornerShape(9.dp),
|
||||
border = BorderStroke(1.dp, colorResource(R.color.grey_600)),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = colorResource(R.color.grey_600)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_reply),
|
||||
contentDescription = stringResource(R.string.open_thread),
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.padding(start = 5.dp, end = 2.dp),
|
||||
tint = colorResource(R.color.grey_600)
|
||||
)
|
||||
Text(
|
||||
text = replyAmountText,
|
||||
modifier = Modifier
|
||||
.padding(end = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ThreadButtonPreviewMultipleReplies() {
|
||||
ThreadButtonComposable(2)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ThreadButtonPreviewOneReply() {
|
||||
ThreadButtonComposable(1)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ThreadButtonPreviewZeroReplies() {
|
||||
ThreadButtonComposable(0)
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage;
|
||||
import com.stfalcon.chatkit.messages.MessageHolders;
|
||||
|
||||
public class UnreadNoticeMessageViewHolder extends MessageHolders.SystemMessageViewHolder<ChatMessage> {
|
||||
|
||||
public UnreadNoticeMessageViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
|
||||
public UnreadNoticeMessageViewHolder(View itemView, Object payload) {
|
||||
super(itemView, payload);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void viewDetached() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void viewAttached() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void viewRecycled() {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
|
||||
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.ui.PlaybackSpeed
|
||||
|
||||
interface VoiceMessageInterface {
|
||||
fun updateMediaPlayerProgressBySlider(message: ChatMessage, progress: Int)
|
||||
fun registerMessageToObservePlaybackSpeedPreferences(userId: String, listener: (speed: PlaybackSpeed) -> Unit)
|
||||
}
|
||||
649
app/src/main/java/com/nextcloud/talk/api/NcApi.java
Normal file
649
app/src/main/java/com/nextcloud/talk/api/NcApi.java
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2021 Tim Krüger <t@timkrueger.me>
|
||||
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.api;
|
||||
|
||||
import com.nextcloud.talk.models.json.capabilities.CapabilitiesOverall;
|
||||
import com.nextcloud.talk.models.json.capabilities.RoomCapabilitiesOverall;
|
||||
import com.nextcloud.talk.models.json.chat.ChatOverall;
|
||||
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage;
|
||||
import com.nextcloud.talk.models.json.chat.ChatShareOverall;
|
||||
import com.nextcloud.talk.models.json.chat.ChatShareOverviewOverall;
|
||||
import com.nextcloud.talk.models.json.conversations.RoomOverall;
|
||||
import com.nextcloud.talk.models.json.conversations.RoomsOverall;
|
||||
import com.nextcloud.talk.models.json.generic.GenericOverall;
|
||||
import com.nextcloud.talk.models.json.generic.Status;
|
||||
import com.nextcloud.talk.models.json.hovercard.HoverCardOverall;
|
||||
import com.nextcloud.talk.models.json.invitation.InvitationOverall;
|
||||
import com.nextcloud.talk.models.json.mention.MentionOverall;
|
||||
import com.nextcloud.talk.models.json.notifications.NotificationOverall;
|
||||
import com.nextcloud.talk.models.json.opengraph.OpenGraphOverall;
|
||||
import com.nextcloud.talk.models.json.participants.AddParticipantOverall;
|
||||
import com.nextcloud.talk.models.json.participants.ParticipantsOverall;
|
||||
import com.nextcloud.talk.models.json.participants.TalkBanOverall;
|
||||
import com.nextcloud.talk.models.json.push.PushRegistrationOverall;
|
||||
import com.nextcloud.talk.models.json.reactions.ReactionsOverall;
|
||||
import com.nextcloud.talk.models.json.reminder.ReminderOverall;
|
||||
import com.nextcloud.talk.models.json.search.ContactsByNumberOverall;
|
||||
import com.nextcloud.talk.models.json.signaling.SignalingOverall;
|
||||
import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOverall;
|
||||
import com.nextcloud.talk.models.json.status.StatusOverall;
|
||||
import com.nextcloud.talk.models.json.unifiedsearch.UnifiedSearchOverall;
|
||||
import com.nextcloud.talk.models.json.userprofile.UserProfileFieldsOverall;
|
||||
import com.nextcloud.talk.models.json.userprofile.UserProfileOverall;
|
||||
import com.nextcloud.talk.polls.repositories.model.PollOverall;
|
||||
import com.nextcloud.talk.translate.repositories.model.LanguagesOverall;
|
||||
import com.nextcloud.talk.translate.repositories.model.TranslationsOverall;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import io.reactivex.Observable;
|
||||
import kotlin.Unit;
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.ResponseBody;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Response;
|
||||
import retrofit2.http.Body;
|
||||
import retrofit2.http.DELETE;
|
||||
import retrofit2.http.Field;
|
||||
import retrofit2.http.FieldMap;
|
||||
import retrofit2.http.FormUrlEncoded;
|
||||
import retrofit2.http.GET;
|
||||
import retrofit2.http.HEAD;
|
||||
import retrofit2.http.Header;
|
||||
import retrofit2.http.Multipart;
|
||||
import retrofit2.http.POST;
|
||||
import retrofit2.http.PUT;
|
||||
import retrofit2.http.Part;
|
||||
import retrofit2.http.Query;
|
||||
import retrofit2.http.QueryMap;
|
||||
import retrofit2.http.Url;
|
||||
|
||||
public interface NcApi {
|
||||
|
||||
/*
|
||||
QueryMap items are as follows:
|
||||
- "format" : "json"
|
||||
- "search" : ""
|
||||
- "perPage" : "200"
|
||||
- "itemType" : "call"
|
||||
|
||||
Server URL is: baseUrl + ocsApiVersion + /apps/files_sharing/api/v1/sharees
|
||||
|
||||
or if we're on 14 and up:
|
||||
|
||||
baseUrl + ocsApiVersion + "/core/autocomplete/get");
|
||||
|
||||
*/
|
||||
@GET
|
||||
Observable<ResponseBody> getContactsWithSearchParam(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Nullable @Query("shareTypes[]") List<String> listOfShareTypes,
|
||||
@QueryMap Map<String, Object> options);
|
||||
|
||||
|
||||
/*
|
||||
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room
|
||||
*/
|
||||
@GET
|
||||
Observable<RoomsOverall> getRooms(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Nullable @Query("includeStatus") Boolean includeStatus);
|
||||
|
||||
/*
|
||||
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room/roomToken
|
||||
*/
|
||||
@GET
|
||||
Observable<RoomOverall> getRoom(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
/*
|
||||
QueryMap items are as follows:
|
||||
- "roomType" : ""
|
||||
- "invite" : ""
|
||||
|
||||
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room
|
||||
*/
|
||||
|
||||
@POST
|
||||
Observable<RoomOverall> createRoom(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@QueryMap Map<String, String> options);
|
||||
|
||||
/*
|
||||
QueryMap items are as follows:
|
||||
- "newParticipant" : "user"
|
||||
|
||||
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room/roomToken/participants
|
||||
*/
|
||||
@POST
|
||||
Observable<AddParticipantOverall> addParticipant(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@QueryMap Map<String,
|
||||
String> options);
|
||||
|
||||
@POST
|
||||
Observable<GenericOverall> resendParticipantInvitations(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
// also used for removing a guest from a conversation
|
||||
@Deprecated
|
||||
@DELETE
|
||||
Observable<GenericOverall> removeParticipantFromConversation(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("participant") String participantId);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> removeAttendeeFromConversation(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("attendeeId") Long attendeeId);
|
||||
|
||||
@Deprecated
|
||||
@POST
|
||||
Observable<GenericOverall> promoteUserToModerator(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("participant") String participantId);
|
||||
|
||||
@Deprecated
|
||||
@DELETE
|
||||
Observable<GenericOverall> demoteModeratorToUser(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("participant") String participantId);
|
||||
|
||||
@POST
|
||||
Observable<GenericOverall> promoteAttendeeToModerator(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("attendeeId") Long attendeeId);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> demoteAttendeeFromModerator(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("attendeeId") Long attendeeId);
|
||||
|
||||
/*
|
||||
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room/roomToken/participants/self
|
||||
*/
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> removeSelfFromRoom(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> deleteRoom(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
/*
|
||||
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /call/callToken
|
||||
*/
|
||||
@GET
|
||||
Observable<ParticipantsOverall> getPeersForCall(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
@GET
|
||||
Observable<ParticipantsOverall> getPeersForCall(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@QueryMap Map<String, Boolean> fields);
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<RoomOverall> joinRoom(@Nullable @Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Nullable @Field("password") String password);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> leaveRoom(@Nullable @Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
/*
|
||||
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /call/callToken
|
||||
*/
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<GenericOverall> joinCall(@Nullable @Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("flags") Integer inCall,
|
||||
@Field("silent") Boolean callWithoutNotification,
|
||||
@Nullable @Field("recordingConsent") Boolean recordingConsent);
|
||||
|
||||
/*
|
||||
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /call/callToken
|
||||
*/
|
||||
@DELETE
|
||||
Observable<GenericOverall> leaveCall(@Nullable @Header("Authorization") String authorization, @Url String url,
|
||||
@Nullable @Query("all") Boolean all);
|
||||
|
||||
@GET
|
||||
Observable<SignalingSettingsOverall> getSignalingSettings(@Nullable @Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
/*
|
||||
QueryMap items are as follows:
|
||||
- "messages" : "message"
|
||||
|
||||
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /signaling
|
||||
*/
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<SignalingOverall> sendSignalingMessages(@Nullable @Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("messages") String messages);
|
||||
|
||||
/*
|
||||
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /signaling
|
||||
*/
|
||||
@GET
|
||||
Observable<SignalingOverall> pullSignalingMessages(@Nullable @Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
/*
|
||||
QueryMap items are as follows:
|
||||
- "format" : "json"
|
||||
|
||||
Server URL is: baseUrl + ocsApiVersion + "/cloud/user"
|
||||
*/
|
||||
|
||||
@GET
|
||||
Observable<UserProfileOverall> getUserProfile(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
|
||||
@GET
|
||||
Observable<UserProfileOverall> getUserData(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> revertStatus(@Header("Authentication") String authorization, @Url String url);
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
Observable<GenericOverall> setUserData(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("key") String key,
|
||||
@Field("value") String value);
|
||||
|
||||
|
||||
/*
|
||||
Server URL is: baseUrl + /status.php
|
||||
*/
|
||||
@GET
|
||||
Observable<Status> getServerStatus(@Url String url);
|
||||
|
||||
|
||||
/*
|
||||
QueryMap items are as follows:
|
||||
- "format" : "json"
|
||||
- "pushTokenHash" : ""
|
||||
- "devicePublicKey" : ""
|
||||
- "proxyServer" : ""
|
||||
|
||||
Server URL is: baseUrl + ocsApiVersion + "/apps/notifications/api/v2/push
|
||||
*/
|
||||
|
||||
@POST
|
||||
Observable<PushRegistrationOverall> registerDeviceForNotificationsWithNextcloud(
|
||||
@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@QueryMap Map<String, String> options);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> unregisterDeviceForNotificationsWithNextcloud(
|
||||
@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<Unit> registerDeviceForNotificationsWithPushProxy(@Url String url,
|
||||
@FieldMap Map<String, String> fields);
|
||||
|
||||
|
||||
/*
|
||||
QueryMap items are as follows:
|
||||
- "deviceIdentifier": "{{deviceIdentifier}}",
|
||||
- "deviceIdentifierSignature": "{{signature}}",
|
||||
- "userPublicKey": "{{userPublicKey}}"
|
||||
*/
|
||||
@DELETE
|
||||
Observable<Void> unregisterDeviceForNotificationsWithProxy(@Url String url,
|
||||
@QueryMap Map<String, String> fields);
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
Observable<Response<GenericOverall>> setPassword2(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("password") String password);
|
||||
|
||||
@GET
|
||||
Observable<CapabilitiesOverall> getCapabilities(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
@GET
|
||||
Observable<CapabilitiesOverall> getCapabilities(@Url String url);
|
||||
|
||||
@GET
|
||||
Observable<RoomCapabilitiesOverall> getRoomCapabilities(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
/*
|
||||
QueryMap items are as follows:
|
||||
- "lookIntoFuture": int (0 or 1),
|
||||
- "limit" : int, range 100-200,
|
||||
- "timeout": used with look into future, 30 default, 60 at most
|
||||
- "lastKnownMessageId", int, use one from X-Chat-Last-Given
|
||||
*/
|
||||
@GET
|
||||
Observable<Response<ChatOverall>> pullChatMessages(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@QueryMap Map<String, Integer> fields);
|
||||
|
||||
/*
|
||||
Fieldmap items are as follows:
|
||||
- "message": ,
|
||||
- "actorDisplayName"
|
||||
*/
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<ChatOverallSingleMessage> sendChatMessage(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("message") CharSequence message,
|
||||
@Field("actorDisplayName") String actorDisplayName,
|
||||
@Field("replyTo") Integer replyTo,
|
||||
@Field("silent") Boolean sendWithoutNotification,
|
||||
@Field("referenceId") String referenceId
|
||||
);
|
||||
|
||||
@GET
|
||||
Observable<Response<ChatShareOverall>> getSharedItems(
|
||||
@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("objectType") String objectType,
|
||||
@Nullable @Query("lastKnownMessageId") Integer lastKnownMessageId,
|
||||
@Nullable @Query("limit") Integer limit);
|
||||
|
||||
@GET
|
||||
Observable<Response<ChatShareOverviewOverall>> getSharedItemsOverview(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Nullable @Query("limit") Integer limit);
|
||||
|
||||
|
||||
@GET
|
||||
Observable<MentionOverall> getMentionAutocompleteSuggestions(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("search") String query,
|
||||
@Nullable @Query("limit") Integer limit,
|
||||
@QueryMap Map<String, String> fields);
|
||||
|
||||
@GET
|
||||
Observable<NotificationOverall> getNcNotification(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<GenericOverall> setNotificationLevel(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("level") int level);
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
Observable<GenericOverall> setConversationReadOnly(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("state") int state);
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<GenericOverall> createRemoteShare(@Nullable @Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("path") String remotePath,
|
||||
@Field("shareWith") String roomToken,
|
||||
@Field("shareType") String shareType,
|
||||
@Field("talkMetaData") String talkMetaData);
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
Observable<GenericOverall> setLobbyForConversation(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("state") Integer state,
|
||||
@Field("timer") Long timer);
|
||||
|
||||
@POST
|
||||
Observable<ContactsByNumberOverall> searchContactsByPhoneNumber(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Body RequestBody search);
|
||||
|
||||
@PUT
|
||||
Observable<Response<GenericOverall>> uploadFile(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Body RequestBody body);
|
||||
|
||||
@HEAD
|
||||
Observable<Response<Void>> checkIfFileExists(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@GET
|
||||
Call<ResponseBody> downloadFile(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@DELETE
|
||||
Observable<ChatOverallSingleMessage> deleteChatMessage(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> deleteAvatar(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
@DELETE
|
||||
Observable<RoomOverall> deleteConversationAvatar(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
|
||||
@Multipart
|
||||
@POST
|
||||
Observable<GenericOverall> uploadAvatar(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Part MultipartBody.Part attachment);
|
||||
|
||||
@Multipart
|
||||
@POST
|
||||
Observable<RoomOverall> uploadConversationAvatar(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Part MultipartBody.Part attachment);
|
||||
|
||||
@GET
|
||||
Observable<UserProfileFieldsOverall> getEditableUserProfileFields(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@GET
|
||||
Call<ResponseBody> downloadResizedImage(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<GenericOverall> sendLocation(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("objectType") String objectType,
|
||||
@Field("objectId") String objectId,
|
||||
@Field("metaData") String metaData);
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<GenericOverall> notificationCalls(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("level") Integer level);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> clearChatHistory(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
@GET
|
||||
Observable<HoverCardOverall> hoverCard(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
// Url is: /api/{apiVersion}/chat/{token}/read
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<GenericOverall> setChatReadMarker(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Nullable @Field("lastReadMessage") Integer lastReadMessage);
|
||||
|
||||
// Url is: /api/{apiVersion}/chat/{token}/read
|
||||
@DELETE
|
||||
Observable<GenericOverall> markRoomAsUnread(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
/*
|
||||
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /listed-room
|
||||
*/
|
||||
@GET
|
||||
Observable<RoomsOverall> getOpenConversations(@Header("Authorization") String authorization, @Url String url,
|
||||
@Query("searchTerm") String searchTerm);
|
||||
|
||||
@GET
|
||||
Observable<StatusOverall> status(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
@GET
|
||||
Observable<StatusOverall> backupStatus(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
@GET
|
||||
Observable<ResponseBody> getPredefinedStatuses(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> statusDeleteMessage(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
Observable<GenericOverall> setPredefinedStatusMessage(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("messageId") String selectedPredefinedMessageId,
|
||||
@Field("clearAt") Long clearAt);
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
Observable<GenericOverall> setCustomStatusMessage(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("statusIcon") String statusIcon,
|
||||
@Field("message") String message,
|
||||
@Field("clearAt") Long clearAt);
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
Observable<GenericOverall> setStatusType(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("statusType") String statusType);
|
||||
|
||||
|
||||
@POST
|
||||
Observable<GenericOverall> sendReaction(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("reaction") String reaction);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> deleteReaction(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("reaction") String reaction);
|
||||
|
||||
@GET
|
||||
Observable<ReactionsOverall> getReactions(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("reaction") String reaction);
|
||||
|
||||
@GET
|
||||
Observable<UnifiedSearchOverall> performUnifiedSearch(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("term") String term,
|
||||
@Query("from") String fromUrl,
|
||||
@Query("limit") Integer limit,
|
||||
@Query("cursor") Integer cursor);
|
||||
|
||||
@GET
|
||||
Observable<PollOverall> getPoll(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<PollOverall> createPoll(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("question") String question,
|
||||
@Field("options[]") List<String> options,
|
||||
@Query("resultMode") Integer resultMode,
|
||||
@Query("maxVotes") Integer maxVotes);
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<PollOverall> votePoll(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("optionIds[]") List<Integer> optionIds);
|
||||
|
||||
@DELETE
|
||||
Observable<PollOverall> closePoll(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@GET
|
||||
Observable<OpenGraphOverall> getOpenGraph(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("reference") String urlToFindPreviewFor);
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<GenericOverall> startRecording(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("status") Integer status);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> stopRecording(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@POST
|
||||
Observable<GenericOverall> requestAssistance(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> withdrawRequestAssistance(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@POST
|
||||
Observable<GenericOverall> sendCommonPostRequest(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> sendCommonDeleteRequest(@Header("Authorization") String authorization, @Url String url);
|
||||
|
||||
|
||||
@POST
|
||||
Observable<TranslationsOverall> translateMessage(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Query("text") String text,
|
||||
@Query("toLanguage") String toLanguage,
|
||||
@Nullable @Query("fromLanguage") String fromLanguage);
|
||||
|
||||
@GET
|
||||
Observable<LanguagesOverall> getLanguages(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@GET
|
||||
Observable<ReminderOverall> getReminder(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> deleteReminder(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
Observable<ReminderOverall> setReminder(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("timestamp") int timestamp);
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
Observable<GenericOverall> setRecordingConsent(@Header("Authorization") String authorization,
|
||||
@Url String url,
|
||||
@Field("recordingConsent") int recordingConsent);
|
||||
|
||||
@GET
|
||||
Observable<InvitationOverall> getInvitations(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@POST
|
||||
Observable<GenericOverall> acceptInvitation(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
|
||||
@DELETE
|
||||
Observable<GenericOverall> rejectInvitation(@Header("Authorization") String authorization,
|
||||
@Url String url);
|
||||
}
|
||||
309
app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt
Normal file
309
app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Sowjanya Kota <sowjanya.kch@gmail.com>
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.api
|
||||
|
||||
import com.nextcloud.talk.conversationinfo.CreateRoomRequest
|
||||
import com.nextcloud.talk.models.json.autocomplete.AutocompleteOverall
|
||||
import com.nextcloud.talk.models.json.chat.ChatOverall
|
||||
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
|
||||
import com.nextcloud.talk.models.json.conversations.RoomOverall
|
||||
import com.nextcloud.talk.models.json.generic.GenericOverall
|
||||
import com.nextcloud.talk.models.json.participants.AddParticipantOverall
|
||||
import com.nextcloud.talk.models.json.participants.TalkBan
|
||||
import com.nextcloud.talk.models.json.participants.TalkBanOverall
|
||||
import com.nextcloud.talk.models.json.profile.ProfileOverall
|
||||
import com.nextcloud.talk.models.json.testNotification.TestNotificationOverall
|
||||
import com.nextcloud.talk.models.json.threads.ThreadOverall
|
||||
import com.nextcloud.talk.models.json.threads.ThreadsOverall
|
||||
import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.Field
|
||||
import retrofit2.http.FormUrlEncoded
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.QueryMap
|
||||
import retrofit2.http.Url
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
interface NcApiCoroutines {
|
||||
@GET
|
||||
@JvmSuppressWildcards
|
||||
suspend fun getContactsWithSearchParam(
|
||||
@Header("Authorization") authorization: String?,
|
||||
@Url url: String?,
|
||||
@Query("shareTypes[]") listOfShareTypes: List<String>?,
|
||||
@QueryMap options: Map<String, Any>?
|
||||
): AutocompleteOverall
|
||||
|
||||
/*
|
||||
QueryMap items are as follows:
|
||||
- "roomType" : ""
|
||||
- "invite" : ""
|
||||
|
||||
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room
|
||||
*/
|
||||
@POST
|
||||
suspend fun createRoom(
|
||||
@Header("Authorization") authorization: String?,
|
||||
@Url url: String?,
|
||||
@QueryMap options: Map<String, String>?
|
||||
): RoomOverall
|
||||
|
||||
@POST
|
||||
suspend fun createRoomWithBody(
|
||||
@Header("Authorization") authorization: String?,
|
||||
@Url url: String?,
|
||||
@Body roomRequest: CreateRoomRequest
|
||||
): RoomOverall
|
||||
|
||||
/*
|
||||
QueryMap items are as follows:
|
||||
- "roomName" : "newName"
|
||||
|
||||
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room/roomToken
|
||||
*/
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
suspend fun renameRoom(
|
||||
@Header("Authorization") authorization: String?,
|
||||
@Url url: String,
|
||||
@Field("roomName") roomName: String?
|
||||
): GenericOverall
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
suspend fun openConversation(
|
||||
@Header("Authorization") authorization: String?,
|
||||
@Url url: String,
|
||||
@Field("scope") scope: Int
|
||||
): GenericOverall
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
suspend fun setConversationDescription(
|
||||
@Header("Authorization") authorization: String?,
|
||||
@Url url: String,
|
||||
@Field("description") description: String?
|
||||
): GenericOverall
|
||||
|
||||
@POST
|
||||
suspend fun addParticipant(
|
||||
@Header("Authorization") authorization: String?,
|
||||
@Url url: String?,
|
||||
@QueryMap options: Map<String, String>?
|
||||
): AddParticipantOverall
|
||||
|
||||
@POST
|
||||
suspend fun makeRoomPublic(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
|
||||
|
||||
@DELETE
|
||||
suspend fun makeRoomPrivate(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
suspend fun setPassword(
|
||||
@Header("Authorization") authorization: String?,
|
||||
@Url url: String?,
|
||||
@Field("password") password: String?
|
||||
): GenericOverall
|
||||
|
||||
@Multipart
|
||||
@POST
|
||||
suspend fun uploadConversationAvatar(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Part attachment: MultipartBody.Part
|
||||
): RoomOverall
|
||||
|
||||
@DELETE
|
||||
suspend fun deleteConversationAvatar(@Header("Authorization") authorization: String, @Url url: String): RoomOverall
|
||||
|
||||
@POST
|
||||
suspend fun archiveConversation(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
|
||||
|
||||
@DELETE
|
||||
suspend fun unarchiveConversation(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
suspend fun sendChatMessage(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Field("message") message: String,
|
||||
@Field("actorDisplayName") actorDisplayName: String,
|
||||
@Field("replyTo") replyTo: Int,
|
||||
@Field("silent") sendWithoutNotification: Boolean,
|
||||
@Field("referenceId") referenceId: String,
|
||||
@Field("threadTitle") threadTitle: String?
|
||||
): ChatOverallSingleMessage
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
suspend fun editChatMessage(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Field("message") message: String
|
||||
): ChatOverallSingleMessage
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
suspend fun banActor(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Field("actorType") actorType: String,
|
||||
@Field("actorId") actorId: String,
|
||||
@Field("internalNote") internalNote: String
|
||||
): TalkBan
|
||||
|
||||
@GET
|
||||
suspend fun listBans(@Header("Authorization") authorization: String, @Url url: String): TalkBanOverall
|
||||
|
||||
@DELETE
|
||||
suspend fun unbanActor(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
|
||||
|
||||
@POST
|
||||
suspend fun addConversationToFavorites(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String
|
||||
): GenericOverall
|
||||
|
||||
@POST
|
||||
suspend fun markConversationAsImportant(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String
|
||||
): GenericOverall
|
||||
|
||||
@DELETE
|
||||
suspend fun markConversationAsUnimportant(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String
|
||||
): GenericOverall
|
||||
|
||||
@DELETE
|
||||
suspend fun removeConversationFromFavorites(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String
|
||||
): GenericOverall
|
||||
|
||||
@POST
|
||||
suspend fun markConversationAsSensitive(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String
|
||||
): GenericOverall
|
||||
|
||||
@DELETE
|
||||
suspend fun markConversationAsInsensitive(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String
|
||||
): GenericOverall
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
suspend fun notificationCalls(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Field("level") level: Int
|
||||
): GenericOverall
|
||||
|
||||
@POST
|
||||
suspend fun setReadStatusPrivacy(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Body body: RequestBody
|
||||
): GenericOverall
|
||||
|
||||
@POST
|
||||
suspend fun setTypingStatusPrivacy(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Body body: RequestBody
|
||||
): GenericOverall
|
||||
|
||||
@DELETE
|
||||
suspend fun clearChatHistory(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT
|
||||
suspend fun setConversationReadOnly(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Field("state") state: Int
|
||||
): GenericOverall
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
suspend fun setNotificationLevel(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Field("level") level: Int
|
||||
): GenericOverall
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
suspend fun setMessageExpiration(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Field("seconds") seconds: Int
|
||||
): GenericOverall
|
||||
|
||||
@GET
|
||||
suspend fun getOutOfOfficeStatusForUser(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String
|
||||
): UserAbsenceOverall
|
||||
|
||||
@POST
|
||||
suspend fun testPushNotifications(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String
|
||||
): TestNotificationOverall
|
||||
|
||||
@GET
|
||||
suspend fun getContextOfChatMessage(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Query("limit") limit: Int
|
||||
): ChatOverall
|
||||
|
||||
@GET
|
||||
suspend fun getNoteToSelfRoom(@Header("Authorization") authorization: String, @Url url: String): RoomOverall
|
||||
|
||||
@GET
|
||||
suspend fun getProfile(@Header("Authorization") authorization: String, @Url url: String): ProfileOverall
|
||||
|
||||
@DELETE
|
||||
suspend fun unbindRoom(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
|
||||
|
||||
@GET
|
||||
suspend fun getThreads(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Query("limit") limit: Int?
|
||||
): ThreadsOverall
|
||||
|
||||
@GET
|
||||
suspend fun getThread(@Header("Authorization") authorization: String, @Url url: String): ThreadOverall
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
suspend fun setThreadNotificationLevel(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Field("level") level: Int
|
||||
): ThreadOverall
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2022 Tim Krüger <t@timkrueger.me>
|
||||
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.application
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Build.VERSION_CODES.P
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.emoji2.bundled.BundledEmojiCompatConfig
|
||||
import androidx.emoji2.text.EmojiCompat
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.multidex.MultiDex
|
||||
import androidx.multidex.MultiDexApplication
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import autodagger.AutoComponent
|
||||
import autodagger.AutoInjector
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.memory.MemoryCache
|
||||
import coil.util.DebugLogger
|
||||
import com.nextcloud.talk.BuildConfig
|
||||
import com.nextcloud.talk.dagger.modules.BusModule
|
||||
import com.nextcloud.talk.dagger.modules.ContextModule
|
||||
import com.nextcloud.talk.dagger.modules.DaosModule
|
||||
import com.nextcloud.talk.dagger.modules.DatabaseModule
|
||||
import com.nextcloud.talk.dagger.modules.ManagerModule
|
||||
import com.nextcloud.talk.dagger.modules.RepositoryModule
|
||||
import com.nextcloud.talk.dagger.modules.RestModule
|
||||
import com.nextcloud.talk.dagger.modules.UtilsModule
|
||||
import com.nextcloud.talk.dagger.modules.ViewModelModule
|
||||
import com.nextcloud.talk.filebrowser.webdav.DavUtils
|
||||
import com.nextcloud.talk.jobs.AccountRemovalWorker
|
||||
import com.nextcloud.talk.jobs.CapabilitiesWorker
|
||||
import com.nextcloud.talk.jobs.SignalingSettingsWorker
|
||||
import com.nextcloud.talk.jobs.WebsocketConnectionsWorker
|
||||
import com.nextcloud.talk.ui.theme.ThemeModule
|
||||
import com.nextcloud.talk.utils.ClosedInterfaceImpl
|
||||
import com.nextcloud.talk.utils.DeviceUtils
|
||||
import com.nextcloud.talk.utils.NotificationUtils
|
||||
import com.nextcloud.talk.utils.database.arbitrarystorage.ArbitraryStorageModule
|
||||
import com.nextcloud.talk.utils.database.user.UserModule
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import com.vanniktech.emoji.EmojiManager
|
||||
import com.vanniktech.emoji.google.GoogleEmojiProvider
|
||||
import de.cotech.hw.SecurityKeyManager
|
||||
import de.cotech.hw.SecurityKeyManagerConfig
|
||||
import okhttp3.OkHttpClient
|
||||
import org.conscrypt.Conscrypt
|
||||
import org.webrtc.PeerConnectionFactory
|
||||
import java.security.Security
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@AutoComponent(
|
||||
modules = [
|
||||
BusModule::class,
|
||||
ContextModule::class,
|
||||
DatabaseModule::class,
|
||||
RestModule::class,
|
||||
UserModule::class,
|
||||
ArbitraryStorageModule::class,
|
||||
ViewModelModule::class,
|
||||
RepositoryModule::class,
|
||||
UtilsModule::class,
|
||||
ThemeModule::class,
|
||||
ManagerModule::class,
|
||||
DaosModule::class
|
||||
]
|
||||
)
|
||||
@Singleton
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class NextcloudTalkApplication :
|
||||
MultiDexApplication(),
|
||||
LifecycleObserver {
|
||||
//region Fields (components)
|
||||
lateinit var componentApplication: NextcloudTalkApplicationComponent
|
||||
private set
|
||||
//endregion
|
||||
|
||||
//region Getters
|
||||
|
||||
@Inject
|
||||
lateinit var appPreferences: AppPreferences
|
||||
|
||||
@Inject
|
||||
lateinit var okHttpClient: OkHttpClient
|
||||
//endregion
|
||||
|
||||
//region private methods
|
||||
private fun initializeWebRtc() {
|
||||
try {
|
||||
PeerConnectionFactory.initialize(
|
||||
PeerConnectionFactory.InitializationOptions.builder(this)
|
||||
.createInitializationOptions()
|
||||
)
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Overridden methods
|
||||
override fun onCreate() {
|
||||
Log.d(TAG, "onCreate")
|
||||
sharedApplication = this
|
||||
|
||||
val securityKeyManager = SecurityKeyManager.getInstance()
|
||||
val securityKeyConfig = SecurityKeyManagerConfig.Builder()
|
||||
.setEnableDebugLogging(BuildConfig.DEBUG)
|
||||
.build()
|
||||
securityKeyManager.init(this, securityKeyConfig)
|
||||
|
||||
initializeWebRtc()
|
||||
buildComponent()
|
||||
DavUtils.registerCustomFactories()
|
||||
|
||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||
|
||||
componentApplication.inject(this)
|
||||
|
||||
Coil.setImageLoader(buildDefaultImageLoader())
|
||||
setAppTheme(appPreferences.theme)
|
||||
super.onCreate()
|
||||
|
||||
ClosedInterfaceImpl().providerInstallerInstallIfNeededAsync()
|
||||
DeviceUtils.ignoreSpecialBatteryFeatures()
|
||||
|
||||
initWorkers()
|
||||
|
||||
val config = BundledEmojiCompatConfig(this)
|
||||
config.setReplaceAll(true)
|
||||
val emojiCompat = EmojiCompat.init(config)
|
||||
|
||||
EmojiManager.install(GoogleEmojiProvider())
|
||||
|
||||
NotificationUtils.registerNotificationChannels(applicationContext, appPreferences)
|
||||
}
|
||||
|
||||
private fun initWorkers() {
|
||||
val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build()
|
||||
val capabilitiesUpdateWork = OneTimeWorkRequest.Builder(CapabilitiesWorker::class.java).build()
|
||||
val signalingSettingsWork = OneTimeWorkRequest.Builder(SignalingSettingsWorker::class.java).build()
|
||||
val websocketConnectionsWorker = OneTimeWorkRequest.Builder(WebsocketConnectionsWorker::class.java).build()
|
||||
|
||||
WorkManager.getInstance(applicationContext)
|
||||
.beginWith(accountRemovalWork)
|
||||
.then(capabilitiesUpdateWork)
|
||||
.then(signalingSettingsWork)
|
||||
.then(websocketConnectionsWorker)
|
||||
.enqueue()
|
||||
|
||||
val periodicCapabilitiesUpdateWork = PeriodicWorkRequest.Builder(
|
||||
CapabilitiesWorker::class.java,
|
||||
HALF_DAY,
|
||||
TimeUnit.HOURS
|
||||
).build()
|
||||
WorkManager.getInstance(applicationContext).enqueueUniquePeriodicWork(
|
||||
"DailyCapabilitiesUpdateWork",
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
periodicCapabilitiesUpdateWork
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
sharedApplication = null
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Protected methods
|
||||
protected fun buildComponent() {
|
||||
componentApplication = DaggerNextcloudTalkApplicationComponent.builder()
|
||||
.busModule(BusModule())
|
||||
.contextModule(ContextModule(applicationContext))
|
||||
.databaseModule(DatabaseModule())
|
||||
.restModule(RestModule(applicationContext))
|
||||
.arbitraryStorageModule(ArbitraryStorageModule())
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base)
|
||||
MultiDex.install(this)
|
||||
}
|
||||
|
||||
private fun buildDefaultImageLoader(): ImageLoader {
|
||||
val imageLoaderBuilder = ImageLoader.Builder(applicationContext)
|
||||
.memoryCache {
|
||||
// Use 50% of the application's available memory.
|
||||
MemoryCache.Builder(applicationContext).maxSizePercent(FIFTY_PERCENT).build()
|
||||
}
|
||||
.crossfade(true) // Show a short crossfade when loading images from network or disk into an ImageView.
|
||||
.components {
|
||||
if (SDK_INT >= P) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
imageLoaderBuilder.logger(DebugLogger())
|
||||
}
|
||||
|
||||
imageLoaderBuilder.okHttpClient(okHttpClient)
|
||||
|
||||
return imageLoaderBuilder.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = NextcloudTalkApplication::class.java.simpleName
|
||||
const val FIFTY_PERCENT = 0.5
|
||||
const val HALF_DAY: Long = 12
|
||||
const val CIPHER_V4_MIGRATION: Int = 7
|
||||
//region Singleton
|
||||
//endregion
|
||||
|
||||
var sharedApplication: NextcloudTalkApplication? = null
|
||||
protected set
|
||||
//endregion
|
||||
|
||||
//region Setters
|
||||
fun setAppTheme(theme: String) {
|
||||
when (theme) {
|
||||
"night_no" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
"night_yes" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
"battery_saver" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY)
|
||||
else ->
|
||||
// will be "follow_system" only for now
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
}
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.arbitrarystorage
|
||||
|
||||
import com.nextcloud.talk.data.storage.ArbitraryStoragesRepository
|
||||
import com.nextcloud.talk.data.storage.model.ArbitraryStorage
|
||||
import io.reactivex.Maybe
|
||||
|
||||
class ArbitraryStorageManager(private val arbitraryStoragesRepository: ArbitraryStoragesRepository) {
|
||||
fun storeStorageSetting(accountIdentifier: Long, key: String, value: String?, objectString: String?) {
|
||||
arbitraryStoragesRepository.saveArbitraryStorage(ArbitraryStorage(accountIdentifier, key, objectString, value))
|
||||
}
|
||||
|
||||
fun getStorageSetting(accountIdentifier: Long, key: String, objectString: String): Maybe<ArbitraryStorage> =
|
||||
arbitraryStoragesRepository.getStorageSetting(accountIdentifier, key, objectString)
|
||||
|
||||
fun deleteAllEntriesForAccountIdentifier(accountIdentifier: Long): Int =
|
||||
arbitraryStoragesRepository.deleteArbitraryStorage(accountIdentifier)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.bottomsheet.items
|
||||
|
||||
import android.widget.ImageView
|
||||
import androidx.annotation.DrawableRes
|
||||
|
||||
interface ListItemWithImage {
|
||||
val title: String
|
||||
fun populateIcon(imageView: ImageView)
|
||||
}
|
||||
|
||||
data class BasicListItemWithImage(@DrawableRes val iconRes: Int, override val title: String) : ListItemWithImage {
|
||||
|
||||
override fun populateIcon(imageView: ImageView) {
|
||||
imageView.setImageResource(iconRes)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.bottomsheet.items
|
||||
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.afollestad.materialdialogs.internal.list.DialogAdapter
|
||||
import com.afollestad.materialdialogs.list.customListAdapter
|
||||
import com.afollestad.materialdialogs.list.getListAdapter
|
||||
|
||||
typealias ListItemListener<IT> =
|
||||
((dialog: MaterialDialog, index: Int, item: IT) -> Unit)?
|
||||
|
||||
@CheckResult
|
||||
fun <IT : ListItemWithImage> MaterialDialog.listItemsWithImage(
|
||||
items: List<IT>,
|
||||
disabledIndices: IntArray? = null,
|
||||
waitForPositiveButton: Boolean = true,
|
||||
selection: ListItemListener<IT> = null
|
||||
): MaterialDialog {
|
||||
if (getListAdapter() != null) {
|
||||
return updateListItemsWithImage(
|
||||
items = items,
|
||||
disabledIndices = disabledIndices
|
||||
)
|
||||
}
|
||||
|
||||
val layoutManager = LinearLayoutManager(windowContext)
|
||||
return customListAdapter(
|
||||
adapter = ListIconDialogAdapter(
|
||||
dialog = this,
|
||||
items = items,
|
||||
disabledItems = disabledIndices,
|
||||
waitForPositiveButton = waitForPositiveButton,
|
||||
selection = selection
|
||||
),
|
||||
layoutManager = layoutManager
|
||||
)
|
||||
}
|
||||
|
||||
fun MaterialDialog.updateListItemsWithImage(
|
||||
items: List<ListItemWithImage>,
|
||||
disabledIndices: IntArray? = null
|
||||
): MaterialDialog {
|
||||
val adapter = getListAdapter()
|
||||
check(adapter != null) {
|
||||
"updateGridItems(...) can't be used before you've created a bottom sheet grid dialog."
|
||||
}
|
||||
if (adapter is DialogAdapter<*, *>) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(adapter as DialogAdapter<ListItemWithImage, *>).replaceItems(items)
|
||||
|
||||
if (disabledIndices != null) {
|
||||
adapter.disableItems(disabledIndices)
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.bottomsheet.items
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.afollestad.materialdialogs.WhichButton
|
||||
import com.afollestad.materialdialogs.actions.hasActionButton
|
||||
import com.afollestad.materialdialogs.actions.hasActionButtons
|
||||
import com.afollestad.materialdialogs.internal.list.DialogAdapter
|
||||
import com.afollestad.materialdialogs.internal.rtl.RtlTextView
|
||||
import com.afollestad.materialdialogs.utils.MDUtil.inflate
|
||||
import com.afollestad.materialdialogs.utils.MDUtil.maybeSetTextColor
|
||||
import com.nextcloud.talk.R
|
||||
|
||||
private const val KEY_ACTIVATED_INDEX = "activated_index"
|
||||
|
||||
internal class ListItemViewHolder(itemView: View, private val adapter: ListIconDialogAdapter<*>) :
|
||||
RecyclerView.ViewHolder(itemView),
|
||||
View.OnClickListener {
|
||||
init {
|
||||
itemView.setOnClickListener(this)
|
||||
}
|
||||
|
||||
val iconView: ImageView = itemView.findViewById(R.id.icon)
|
||||
val titleView: RtlTextView = itemView.findViewById(R.id.title)
|
||||
|
||||
override fun onClick(view: View) = adapter.itemClicked(adapterPosition)
|
||||
}
|
||||
|
||||
internal class ListIconDialogAdapter<IT : ListItemWithImage>(
|
||||
private var dialog: MaterialDialog,
|
||||
private var items: List<IT>,
|
||||
disabledItems: IntArray?,
|
||||
private var waitForPositiveButton: Boolean,
|
||||
private var selection: ListItemListener<IT>
|
||||
) : RecyclerView.Adapter<ListItemViewHolder>(),
|
||||
DialogAdapter<IT, ListItemListener<IT>> {
|
||||
|
||||
private var disabledIndices: IntArray = disabledItems ?: IntArray(0)
|
||||
|
||||
fun itemClicked(index: Int) {
|
||||
if (waitForPositiveButton && dialog.hasActionButton(WhichButton.POSITIVE)) {
|
||||
// Wait for positive action button, mark clicked item as activated so that we can call the
|
||||
// selection listener when the button is pressed.
|
||||
val lastActivated = dialog.config[KEY_ACTIVATED_INDEX] as? Int
|
||||
dialog.config[KEY_ACTIVATED_INDEX] = index
|
||||
if (lastActivated != null) {
|
||||
notifyItemChanged(lastActivated)
|
||||
}
|
||||
notifyItemChanged(index)
|
||||
} else {
|
||||
// Don't wait for action buttons, call listener and dismiss if auto dismiss is applicable
|
||||
this.selection?.invoke(dialog, index, this.items[index])
|
||||
if (dialog.autoDismissEnabled && !dialog.hasActionButtons()) {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemViewHolder {
|
||||
val listItemView: View = parent.inflate(dialog.windowContext, R.layout.menu_item_sheet)
|
||||
val viewHolder = ListItemViewHolder(
|
||||
itemView = listItemView,
|
||||
adapter = this
|
||||
)
|
||||
viewHolder.titleView.maybeSetTextColor(dialog.windowContext, R.attr.md_color_content)
|
||||
return viewHolder
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
override fun onBindViewHolder(holder: ListItemViewHolder, position: Int) {
|
||||
holder.itemView.isEnabled = !disabledIndices.contains(position)
|
||||
val currentItem = items[position]
|
||||
|
||||
holder.titleView.text = currentItem.title
|
||||
currentItem.populateIcon(holder.iconView)
|
||||
|
||||
val activatedIndex = dialog.config[KEY_ACTIVATED_INDEX] as? Int
|
||||
holder.itemView.isActivated = activatedIndex != null && activatedIndex == position
|
||||
|
||||
if (dialog.bodyFont != null) {
|
||||
holder.titleView.typeface = dialog.bodyFont
|
||||
}
|
||||
}
|
||||
|
||||
override fun positiveButtonClicked() {
|
||||
val activatedIndex = dialog.config[KEY_ACTIVATED_INDEX] as? Int
|
||||
if (activatedIndex != null) {
|
||||
selection?.invoke(dialog, activatedIndex, items[activatedIndex])
|
||||
dialog.config.remove(KEY_ACTIVATED_INDEX)
|
||||
}
|
||||
}
|
||||
|
||||
override fun replaceItems(items: List<IT>, listener: ListItemListener<IT>) {
|
||||
this.items = items
|
||||
if (listener != null) {
|
||||
this.selection = listener
|
||||
}
|
||||
this.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun disableItems(indices: IntArray) {
|
||||
this.disabledIndices = indices
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun checkItems(indices: IntArray) = Unit
|
||||
|
||||
override fun uncheckItems(indices: IntArray) = Unit
|
||||
|
||||
override fun toggleItems(indices: IntArray) = Unit
|
||||
|
||||
override fun checkAllItems() = Unit
|
||||
|
||||
override fun uncheckAllItems() = Unit
|
||||
|
||||
override fun toggleAllChecked() = Unit
|
||||
|
||||
override fun isItemChecked(index: Int) = false
|
||||
}
|
||||
219
app/src/main/java/com/nextcloud/talk/call/CallParticipant.java
Normal file
219
app/src/main/java/com/nextcloud/talk/call/CallParticipant.java
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import com.nextcloud.talk.models.json.participants.Participant;
|
||||
import com.nextcloud.talk.signaling.SignalingMessageReceiver;
|
||||
import com.nextcloud.talk.webrtc.PeerConnectionWrapper;
|
||||
|
||||
import org.webrtc.MediaStream;
|
||||
import org.webrtc.PeerConnection;
|
||||
|
||||
/**
|
||||
* Model for (remote) call participants.
|
||||
* <p>
|
||||
* This class keeps track of the state changes in a call participant and updates its data model as needed. View classes
|
||||
* are expected to directly use the read-only data model.
|
||||
*/
|
||||
public class CallParticipant {
|
||||
|
||||
private final SignalingMessageReceiver.CallParticipantMessageListener callParticipantMessageListener =
|
||||
new SignalingMessageReceiver.CallParticipantMessageListener() {
|
||||
@Override
|
||||
public void onRaiseHand(boolean state, long timestamp) {
|
||||
callParticipantModel.setRaisedHand(state, timestamp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReaction(String reaction) {
|
||||
callParticipantModel.emitReaction(reaction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnshareScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
private final PeerConnectionWrapper.PeerConnectionObserver peerConnectionObserver =
|
||||
new PeerConnectionWrapper.PeerConnectionObserver() {
|
||||
@Override
|
||||
public void onStreamAdded(MediaStream mediaStream) {
|
||||
handleStreamChange(mediaStream);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStreamRemoved(MediaStream mediaStream) {
|
||||
handleStreamChange(mediaStream);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIceConnectionStateChanged(PeerConnection.IceConnectionState iceConnectionState) {
|
||||
handleIceConnectionStateChange(iceConnectionState);
|
||||
}
|
||||
};
|
||||
|
||||
private final PeerConnectionWrapper.PeerConnectionObserver screenPeerConnectionObserver =
|
||||
new PeerConnectionWrapper.PeerConnectionObserver() {
|
||||
@Override
|
||||
public void onStreamAdded(MediaStream mediaStream) {
|
||||
callParticipantModel.setScreenMediaStream(mediaStream);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStreamRemoved(MediaStream mediaStream) {
|
||||
callParticipantModel.setScreenMediaStream(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIceConnectionStateChanged(PeerConnection.IceConnectionState iceConnectionState) {
|
||||
callParticipantModel.setScreenIceConnectionState(iceConnectionState);
|
||||
}
|
||||
};
|
||||
|
||||
// DataChannel messages are sent only in video peers; (sender) screen peers do not even open them.
|
||||
private final PeerConnectionWrapper.DataChannelMessageListener dataChannelMessageListener =
|
||||
new PeerConnectionWrapper.DataChannelMessageListener() {
|
||||
@Override
|
||||
public void onAudioOn() {
|
||||
callParticipantModel.setAudioAvailable(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAudioOff() {
|
||||
callParticipantModel.setAudioAvailable(Boolean.FALSE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVideoOn() {
|
||||
callParticipantModel.setVideoAvailable(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVideoOff() {
|
||||
callParticipantModel.setVideoAvailable(Boolean.FALSE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNickChanged(String nick) {
|
||||
callParticipantModel.setNick(nick);
|
||||
}
|
||||
};
|
||||
|
||||
private final MutableCallParticipantModel callParticipantModel;
|
||||
|
||||
private final SignalingMessageReceiver signalingMessageReceiver;
|
||||
|
||||
private PeerConnectionWrapper peerConnectionWrapper;
|
||||
private PeerConnectionWrapper screenPeerConnectionWrapper;
|
||||
|
||||
public CallParticipant(String sessionId, SignalingMessageReceiver signalingMessageReceiver) {
|
||||
callParticipantModel = new MutableCallParticipantModel(sessionId);
|
||||
|
||||
this.signalingMessageReceiver = signalingMessageReceiver;
|
||||
signalingMessageReceiver.addListener(callParticipantMessageListener, sessionId);
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
signalingMessageReceiver.removeListener(callParticipantMessageListener);
|
||||
|
||||
if (peerConnectionWrapper != null) {
|
||||
peerConnectionWrapper.removeObserver(peerConnectionObserver);
|
||||
peerConnectionWrapper.removeListener(dataChannelMessageListener);
|
||||
}
|
||||
if (screenPeerConnectionWrapper != null) {
|
||||
screenPeerConnectionWrapper.removeObserver(screenPeerConnectionObserver);
|
||||
}
|
||||
}
|
||||
|
||||
public CallParticipantModel getCallParticipantModel() {
|
||||
return callParticipantModel;
|
||||
}
|
||||
|
||||
public void setActor(Participant.ActorType actorType, String actorId) {
|
||||
callParticipantModel.setActor(actorType, actorId);
|
||||
}
|
||||
|
||||
public void setUserId(String userId) {
|
||||
callParticipantModel.setUserId(userId);
|
||||
}
|
||||
|
||||
public void setNick(String nick) {
|
||||
callParticipantModel.setNick(nick);
|
||||
}
|
||||
|
||||
public void setInternal(Boolean internal) {
|
||||
callParticipantModel.setInternal(internal);
|
||||
}
|
||||
|
||||
public void setPeerConnectionWrapper(PeerConnectionWrapper peerConnectionWrapper) {
|
||||
if (this.peerConnectionWrapper != null) {
|
||||
this.peerConnectionWrapper.removeObserver(peerConnectionObserver);
|
||||
this.peerConnectionWrapper.removeListener(dataChannelMessageListener);
|
||||
}
|
||||
|
||||
this.peerConnectionWrapper = peerConnectionWrapper;
|
||||
|
||||
if (this.peerConnectionWrapper == null) {
|
||||
callParticipantModel.setIceConnectionState(null);
|
||||
callParticipantModel.setMediaStream(null);
|
||||
callParticipantModel.setAudioAvailable(null);
|
||||
callParticipantModel.setVideoAvailable(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
handleIceConnectionStateChange(this.peerConnectionWrapper.getPeerConnection().iceConnectionState());
|
||||
handleStreamChange(this.peerConnectionWrapper.getStream());
|
||||
|
||||
this.peerConnectionWrapper.addObserver(peerConnectionObserver);
|
||||
this.peerConnectionWrapper.addListener(dataChannelMessageListener);
|
||||
}
|
||||
|
||||
private void handleIceConnectionStateChange(PeerConnection.IceConnectionState iceConnectionState) {
|
||||
callParticipantModel.setIceConnectionState(iceConnectionState);
|
||||
|
||||
if (iceConnectionState == PeerConnection.IceConnectionState.NEW ||
|
||||
iceConnectionState == PeerConnection.IceConnectionState.CHECKING) {
|
||||
callParticipantModel.setAudioAvailable(null);
|
||||
callParticipantModel.setVideoAvailable(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleStreamChange(MediaStream mediaStream) {
|
||||
if (mediaStream == null) {
|
||||
callParticipantModel.setMediaStream(null);
|
||||
callParticipantModel.setVideoAvailable(Boolean.FALSE);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
boolean hasAtLeastOneVideoStream = mediaStream.videoTracks != null && !mediaStream.videoTracks.isEmpty();
|
||||
|
||||
callParticipantModel.setMediaStream(mediaStream);
|
||||
callParticipantModel.setVideoAvailable(hasAtLeastOneVideoStream);
|
||||
}
|
||||
|
||||
public void setScreenPeerConnectionWrapper(PeerConnectionWrapper screenPeerConnectionWrapper) {
|
||||
if (this.screenPeerConnectionWrapper != null) {
|
||||
this.screenPeerConnectionWrapper.removeObserver(screenPeerConnectionObserver);
|
||||
}
|
||||
|
||||
this.screenPeerConnectionWrapper = screenPeerConnectionWrapper;
|
||||
|
||||
if (this.screenPeerConnectionWrapper == null) {
|
||||
callParticipantModel.setScreenIceConnectionState(null);
|
||||
callParticipantModel.setScreenMediaStream(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
callParticipantModel.setScreenIceConnectionState(this.screenPeerConnectionWrapper.getPeerConnection().iceConnectionState());
|
||||
callParticipantModel.setScreenMediaStream(this.screenPeerConnectionWrapper.getStream());
|
||||
|
||||
this.screenPeerConnectionWrapper.addObserver(screenPeerConnectionObserver);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import com.nextcloud.talk.models.json.participants.Participant;
|
||||
import com.nextcloud.talk.signaling.SignalingMessageReceiver;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Helper class to keep track of the participants in a call based on the signaling messages.
|
||||
* <p>
|
||||
* The CallParticipantList adds a listener for participant list messages as soon as it is created and starts tracking
|
||||
* the call participants until destroyed. Notifications about the changes can be received by adding an observer to the
|
||||
* CallParticipantList; note that no sorting is guaranteed on the participants.
|
||||
*/
|
||||
public class CallParticipantList {
|
||||
|
||||
private final CallParticipantListNotifier callParticipantListNotifier = new CallParticipantListNotifier();
|
||||
|
||||
private final SignalingMessageReceiver signalingMessageReceiver;
|
||||
|
||||
public interface Observer {
|
||||
void onCallParticipantsChanged(Collection<Participant> joined, Collection<Participant> updated,
|
||||
Collection<Participant> left, Collection<Participant> unchanged);
|
||||
void onCallEndedForAll();
|
||||
}
|
||||
|
||||
private final SignalingMessageReceiver.ParticipantListMessageListener participantListMessageListener =
|
||||
new SignalingMessageReceiver.ParticipantListMessageListener() {
|
||||
|
||||
private final Map<String, Participant> callParticipants = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public void onUsersInRoom(List<Participant> participants) {
|
||||
processParticipantList(participants);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onParticipantsUpdate(List<Participant> participants) {
|
||||
processParticipantList(participants);
|
||||
}
|
||||
|
||||
private void processParticipantList(List<Participant> participants) {
|
||||
Collection<Participant> joined = new ArrayList<>();
|
||||
Collection<Participant> updated = new ArrayList<>();
|
||||
Collection<Participant> left = new ArrayList<>();
|
||||
Collection<Participant> unchanged = new ArrayList<>();
|
||||
|
||||
Collection<Participant> knownCallParticipantsNotFound = new ArrayList<>(callParticipants.values());
|
||||
|
||||
for (Participant participant : participants) {
|
||||
String sessionId = participant.getSessionId();
|
||||
Participant callParticipant = callParticipants.get(sessionId);
|
||||
|
||||
boolean knownCallParticipant = callParticipant != null;
|
||||
if (!knownCallParticipant && participant.getInCall() != Participant.InCallFlags.DISCONNECTED) {
|
||||
callParticipants.put(sessionId, copyParticipant(participant));
|
||||
joined.add(copyParticipant(participant));
|
||||
} else if (knownCallParticipant && participant.getInCall() == Participant.InCallFlags.DISCONNECTED) {
|
||||
callParticipants.remove(sessionId);
|
||||
// No need to copy it, as it will be no longer used.
|
||||
callParticipant.setInCall(Participant.InCallFlags.DISCONNECTED);
|
||||
left.add(callParticipant);
|
||||
} else if (knownCallParticipant && callParticipant.getInCall() != participant.getInCall()) {
|
||||
callParticipant.setInCall(participant.getInCall());
|
||||
updated.add(copyParticipant(participant));
|
||||
} else if (knownCallParticipant) {
|
||||
unchanged.add(copyParticipant(participant));
|
||||
}
|
||||
|
||||
if (knownCallParticipant) {
|
||||
knownCallParticipantsNotFound.remove(callParticipant);
|
||||
}
|
||||
}
|
||||
|
||||
for (Participant callParticipant : knownCallParticipantsNotFound) {
|
||||
callParticipants.remove(callParticipant.getSessionId());
|
||||
// No need to copy it, as it will be no longer used.
|
||||
callParticipant.setInCall(Participant.InCallFlags.DISCONNECTED);
|
||||
}
|
||||
left.addAll(knownCallParticipantsNotFound);
|
||||
|
||||
if (!joined.isEmpty() || !updated.isEmpty() || !left.isEmpty()) {
|
||||
callParticipantListNotifier.notifyChanged(joined, updated, left, unchanged);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAllParticipantsUpdate(long inCall) {
|
||||
if (inCall != Participant.InCallFlags.DISCONNECTED) {
|
||||
// Updating all participants is expected to happen only to disconnect them.
|
||||
return;
|
||||
}
|
||||
|
||||
callParticipantListNotifier.notifyCallEndedForAll();
|
||||
|
||||
Collection<Participant> joined = new ArrayList<>();
|
||||
Collection<Participant> updated = new ArrayList<>();
|
||||
Collection<Participant> left = new ArrayList<>(callParticipants.size());
|
||||
Collection<Participant> unchanged = new ArrayList<>();
|
||||
|
||||
for (Participant callParticipant : callParticipants.values()) {
|
||||
// No need to copy it, as it will be no longer used.
|
||||
callParticipant.setInCall(Participant.InCallFlags.DISCONNECTED);
|
||||
left.add(callParticipant);
|
||||
}
|
||||
callParticipants.clear();
|
||||
|
||||
if (!left.isEmpty()) {
|
||||
callParticipantListNotifier.notifyChanged(joined, updated, left, unchanged);
|
||||
}
|
||||
}
|
||||
|
||||
private Participant copyParticipant(Participant participant) {
|
||||
Participant copiedParticipant = new Participant();
|
||||
copiedParticipant.setActorId(participant.getActorId());
|
||||
copiedParticipant.setActorType(participant.getActorType());
|
||||
copiedParticipant.setInCall(participant.getInCall());
|
||||
copiedParticipant.setInternal(participant.getInternal());
|
||||
copiedParticipant.setLastPing(participant.getLastPing());
|
||||
copiedParticipant.setSessionId(participant.getSessionId());
|
||||
copiedParticipant.setType(participant.getType());
|
||||
copiedParticipant.setUserId(participant.getUserId());
|
||||
|
||||
return copiedParticipant;
|
||||
}
|
||||
};
|
||||
|
||||
public CallParticipantList(SignalingMessageReceiver signalingMessageReceiver) {
|
||||
this.signalingMessageReceiver = signalingMessageReceiver;
|
||||
this.signalingMessageReceiver.addListener(participantListMessageListener);
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
signalingMessageReceiver.removeListener(participantListMessageListener);
|
||||
}
|
||||
|
||||
public void addObserver(Observer observer) {
|
||||
callParticipantListNotifier.addObserver(observer);
|
||||
}
|
||||
|
||||
public void removeObserver(Observer observer) {
|
||||
callParticipantListNotifier.removeObserver(observer);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import com.nextcloud.talk.models.json.participants.Participant;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Helper class to register and notify CallParticipantList.Observers.
|
||||
* <p>
|
||||
* This class is only meant for internal use by CallParticipantList; listeners must register themselves against
|
||||
* a CallParticipantList rather than against a CallParticipantListNotifier.
|
||||
*/
|
||||
class CallParticipantListNotifier {
|
||||
|
||||
private final Set<CallParticipantList.Observer> callParticipantListObservers = new LinkedHashSet<>();
|
||||
|
||||
public synchronized void addObserver(CallParticipantList.Observer observer) {
|
||||
if (observer == null) {
|
||||
throw new IllegalArgumentException("CallParticipantList.Observer can not be null");
|
||||
}
|
||||
|
||||
callParticipantListObservers.add(observer);
|
||||
}
|
||||
|
||||
public synchronized void removeObserver(CallParticipantList.Observer observer) {
|
||||
callParticipantListObservers.remove(observer);
|
||||
}
|
||||
|
||||
public synchronized void notifyChanged(Collection<Participant> joined, Collection<Participant> updated,
|
||||
Collection<Participant> left, Collection<Participant> unchanged) {
|
||||
for (CallParticipantList.Observer observer : new ArrayList<>(callParticipantListObservers)) {
|
||||
observer.onCallParticipantsChanged(joined, updated, left, unchanged);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void notifyCallEndedForAll() {
|
||||
for (CallParticipantList.Observer observer : new ArrayList<>(callParticipantListObservers)) {
|
||||
observer.onCallEndedForAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import android.os.Handler;
|
||||
|
||||
import com.nextcloud.talk.models.json.participants.Participant;
|
||||
|
||||
import org.webrtc.MediaStream;
|
||||
import org.webrtc.PeerConnection;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Read-only data model for (remote) call participants.
|
||||
* <p>
|
||||
* If the hand was never raised null is returned by "getRaisedHand()". Otherwise a RaisedHand object is returned with
|
||||
* the current state (raised or not) and the timestamp when the raised hand state last changed.
|
||||
* <p>
|
||||
* The received audio and video are available only if the participant is sending them and also has them enabled.
|
||||
* Before a connection is established it is not known whether audio and video are available or not, so null is returned
|
||||
* in that case (therefore it should not be autoboxed to a plain boolean without checking that).
|
||||
* <p>
|
||||
* Audio and video in screen shares, on the other hand, are always seen as available.
|
||||
* <p>
|
||||
* Actor type and actor id will be set only in Talk >= 20.
|
||||
* <p>
|
||||
* Clients of the model can observe it with CallParticipantModel.Observer to be notified when any value changes.
|
||||
* Getters called after receiving a notification are guaranteed to provide at least the value that triggered the
|
||||
* notification, but it may return even a more up to date one (so getting the value again on the following
|
||||
* notification may return the same value as before).
|
||||
* <p>
|
||||
* Besides onChange(), which notifies about changes in the model values, CallParticipantModel.Observer provides
|
||||
* additional methods to be notified about one-time events that are not reflected in the model values, like reactions.
|
||||
*/
|
||||
public class CallParticipantModel {
|
||||
|
||||
protected final CallParticipantModelNotifier callParticipantModelNotifier = new CallParticipantModelNotifier();
|
||||
|
||||
protected final String sessionId;
|
||||
|
||||
protected Data<Participant.ActorType> actorType;
|
||||
protected Data<String> actorId;
|
||||
protected Data<String> userId;
|
||||
protected Data<String> nick;
|
||||
|
||||
protected Data<Boolean> internal;
|
||||
|
||||
protected Data<RaisedHand> raisedHand;
|
||||
|
||||
protected Data<PeerConnection.IceConnectionState> iceConnectionState;
|
||||
protected Data<MediaStream> mediaStream;
|
||||
protected Data<Boolean> audioAvailable;
|
||||
protected Data<Boolean> videoAvailable;
|
||||
|
||||
protected Data<PeerConnection.IceConnectionState> screenIceConnectionState;
|
||||
protected Data<MediaStream> screenMediaStream;
|
||||
|
||||
public interface Observer {
|
||||
void onChange();
|
||||
void onReaction(String reaction);
|
||||
}
|
||||
|
||||
protected class Data<T> {
|
||||
|
||||
private T value;
|
||||
|
||||
public T getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public void setValue(T value) {
|
||||
if (Objects.equals(this.value, value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = value;
|
||||
|
||||
callParticipantModelNotifier.notifyChange();
|
||||
}
|
||||
}
|
||||
|
||||
public CallParticipantModel(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
|
||||
this.actorType = new Data<>();
|
||||
this.actorId = new Data<>();
|
||||
this.userId = new Data<>();
|
||||
this.nick = new Data<>();
|
||||
|
||||
this.internal = new Data<>();
|
||||
|
||||
this.raisedHand = new Data<>();
|
||||
|
||||
this.iceConnectionState = new Data<>();
|
||||
this.mediaStream = new Data<>();
|
||||
this.audioAvailable = new Data<>();
|
||||
this.videoAvailable = new Data<>();
|
||||
|
||||
this.screenIceConnectionState = new Data<>();
|
||||
this.screenMediaStream = new Data<>();
|
||||
}
|
||||
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public Participant.ActorType getActorType() {
|
||||
return actorType.getValue();
|
||||
}
|
||||
|
||||
public String getActorId() {
|
||||
return actorId.getValue();
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId.getValue();
|
||||
}
|
||||
|
||||
public String getNick() {
|
||||
return nick.getValue();
|
||||
}
|
||||
|
||||
public Boolean isInternal() {
|
||||
return internal.getValue();
|
||||
}
|
||||
|
||||
public RaisedHand getRaisedHand() {
|
||||
return raisedHand.getValue();
|
||||
}
|
||||
|
||||
public PeerConnection.IceConnectionState getIceConnectionState() {
|
||||
return iceConnectionState.getValue();
|
||||
}
|
||||
|
||||
public MediaStream getMediaStream() {
|
||||
return mediaStream.getValue();
|
||||
}
|
||||
|
||||
public Boolean isAudioAvailable() {
|
||||
return audioAvailable.getValue();
|
||||
}
|
||||
|
||||
public Boolean isVideoAvailable() {
|
||||
return videoAvailable.getValue();
|
||||
}
|
||||
|
||||
public PeerConnection.IceConnectionState getScreenIceConnectionState() {
|
||||
return screenIceConnectionState.getValue();
|
||||
}
|
||||
|
||||
public MediaStream getScreenMediaStream() {
|
||||
return screenMediaStream.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an Observer to be notified when any value changes.
|
||||
*
|
||||
* @param observer the Observer
|
||||
* @see CallParticipantModel#addObserver(Observer, Handler)
|
||||
*/
|
||||
public void addObserver(Observer observer) {
|
||||
addObserver(observer, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an observer to be notified when any value changes.
|
||||
* <p>
|
||||
* The observer will be notified on the thread associated to the given handler. If no handler is given the
|
||||
* observer will be immediately notified on the same thread that changed the value; the observer will be
|
||||
* immediately notified too if the thread of the handler is the same thread that changed the value.
|
||||
* <p>
|
||||
* An observer is expected to be added only once. If the same observer is added again it will be notified just
|
||||
* once on the thread of the last handler.
|
||||
*
|
||||
* @param observer the Observer
|
||||
* @param handler a Handler for the thread to be notified on
|
||||
*/
|
||||
public void addObserver(Observer observer, Handler handler) {
|
||||
callParticipantModelNotifier.addObserver(observer, handler);
|
||||
}
|
||||
|
||||
public void removeObserver(Observer observer) {
|
||||
callParticipantModelNotifier.removeObserver(observer);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Helper class to register and notify CallParticipantModel.Observers.
|
||||
* <p>
|
||||
* This class is only meant for internal use by CallParticipantModel; observers must register themselves against a
|
||||
* CallParticipantModel rather than against a CallParticipantModelNotifier.
|
||||
*/
|
||||
class CallParticipantModelNotifier {
|
||||
|
||||
private final List<CallParticipantModelObserverOn> callParticipantModelObserversOn = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Helper class to associate a CallParticipantModel.Observer with a Handler.
|
||||
*/
|
||||
private static class CallParticipantModelObserverOn {
|
||||
public final CallParticipantModel.Observer observer;
|
||||
public final Handler handler;
|
||||
|
||||
private CallParticipantModelObserverOn(CallParticipantModel.Observer observer, Handler handler) {
|
||||
this.observer = observer;
|
||||
this.handler = handler;
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void addObserver(CallParticipantModel.Observer observer, Handler handler) {
|
||||
if (observer == null) {
|
||||
throw new IllegalArgumentException("CallParticipantModel.Observer can not be null");
|
||||
}
|
||||
|
||||
removeObserver(observer);
|
||||
|
||||
callParticipantModelObserversOn.add(new CallParticipantModelObserverOn(observer, handler));
|
||||
}
|
||||
|
||||
public synchronized void removeObserver(CallParticipantModel.Observer observer) {
|
||||
Iterator<CallParticipantModelObserverOn> it = callParticipantModelObserversOn.iterator();
|
||||
while (it.hasNext()) {
|
||||
CallParticipantModelObserverOn observerOn = it.next();
|
||||
|
||||
if (observerOn.observer == observer) {
|
||||
it.remove();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void notifyChange() {
|
||||
for (CallParticipantModelObserverOn observerOn : new ArrayList<>(callParticipantModelObserversOn)) {
|
||||
if (observerOn.handler == null || observerOn.handler.getLooper() == Looper.myLooper()) {
|
||||
observerOn.observer.onChange();
|
||||
} else {
|
||||
observerOn.handler.post(() -> {
|
||||
observerOn.observer.onChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void notifyReaction(String reaction) {
|
||||
for (CallParticipantModelObserverOn observerOn : new ArrayList<>(callParticipantModelObserversOn)) {
|
||||
if (observerOn.handler == null || observerOn.handler.getLooper() == Looper.myLooper()) {
|
||||
observerOn.observer.onReaction(reaction);
|
||||
} else {
|
||||
observerOn.handler.post(() -> {
|
||||
observerOn.observer.onReaction(reaction);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import android.os.Handler;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Read-only data model for local call participants.
|
||||
* <p>
|
||||
* Clients of the model can observe it with LocalCallParticipantModel.Observer to be notified when any value changes.
|
||||
* Getters called after receiving a notification are guaranteed to provide at least the value that triggered the
|
||||
* notification, but it may return even a more up to date one (so getting the value again on the following notification
|
||||
* may return the same value as before).
|
||||
*/
|
||||
public class LocalCallParticipantModel {
|
||||
|
||||
protected final LocalCallParticipantModelNotifier localCallParticipantModelNotifier =
|
||||
new LocalCallParticipantModelNotifier();
|
||||
|
||||
protected Data<Boolean> audioEnabled;
|
||||
protected Data<Boolean> speaking;
|
||||
protected Data<Boolean> speakingWhileMuted;
|
||||
protected Data<Boolean> videoEnabled;
|
||||
|
||||
public interface Observer {
|
||||
void onChange();
|
||||
}
|
||||
|
||||
protected class Data<T> {
|
||||
|
||||
private T value;
|
||||
|
||||
public Data() {
|
||||
}
|
||||
|
||||
public Data(T value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public T getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public void setValue(T value) {
|
||||
if (Objects.equals(this.value, value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = value;
|
||||
|
||||
localCallParticipantModelNotifier.notifyChange();
|
||||
}
|
||||
}
|
||||
|
||||
public LocalCallParticipantModel() {
|
||||
this.audioEnabled = new Data<>(Boolean.FALSE);
|
||||
this.speaking = new Data<>(Boolean.FALSE);
|
||||
this.speakingWhileMuted = new Data<>(Boolean.FALSE);
|
||||
this.videoEnabled = new Data<>(Boolean.FALSE);
|
||||
}
|
||||
|
||||
public Boolean isAudioEnabled() {
|
||||
return audioEnabled.getValue();
|
||||
}
|
||||
|
||||
public Boolean isSpeaking() {
|
||||
return speaking.getValue();
|
||||
}
|
||||
|
||||
public Boolean isSpeakingWhileMuted() {
|
||||
return speakingWhileMuted.getValue();
|
||||
}
|
||||
|
||||
public Boolean isVideoEnabled() {
|
||||
return videoEnabled.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an Observer to be notified when any value changes.
|
||||
*
|
||||
* @param observer the Observer
|
||||
* @see LocalCallParticipantModel#addObserver(Observer, Handler)
|
||||
*/
|
||||
public void addObserver(Observer observer) {
|
||||
addObserver(observer, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an observer to be notified when any value changes.
|
||||
* <p>
|
||||
* The observer will be notified on the thread associated to the given handler. If no handler is given the
|
||||
* observer will be immediately notified on the same thread that changed the value; the observer will be
|
||||
* immediately notified too if the thread of the handler is the same thread that changed the value.
|
||||
* <p>
|
||||
* An observer is expected to be added only once. If the same observer is added again it will be notified just
|
||||
* once on the thread of the last handler.
|
||||
*
|
||||
* @param observer the Observer
|
||||
* @param handler a Handler for the thread to be notified on
|
||||
*/
|
||||
public void addObserver(Observer observer, Handler handler) {
|
||||
localCallParticipantModelNotifier.addObserver(observer, handler);
|
||||
}
|
||||
|
||||
public void removeObserver(Observer observer) {
|
||||
localCallParticipantModelNotifier.removeObserver(observer);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Helper class to register and notify LocalCallParticipantModel.Observers.
|
||||
* <p>
|
||||
* This class is only meant for internal use by LocalCallParticipantModel; observers must register themselves against a
|
||||
* LocalCallParticipantModel rather than against a LocalCallParticipantModelNotifier.
|
||||
*/
|
||||
class LocalCallParticipantModelNotifier {
|
||||
|
||||
private final List<LocalCallParticipantModelObserverOn> localCallParticipantModelObserversOn = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Helper class to associate a LocalCallParticipantModel.Observer with a Handler.
|
||||
*/
|
||||
private static class LocalCallParticipantModelObserverOn {
|
||||
public final LocalCallParticipantModel.Observer observer;
|
||||
public final Handler handler;
|
||||
|
||||
private LocalCallParticipantModelObserverOn(LocalCallParticipantModel.Observer observer, Handler handler) {
|
||||
this.observer = observer;
|
||||
this.handler = handler;
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void addObserver(LocalCallParticipantModel.Observer observer, Handler handler) {
|
||||
if (observer == null) {
|
||||
throw new IllegalArgumentException("LocalCallParticipantModel.Observer can not be null");
|
||||
}
|
||||
|
||||
removeObserver(observer);
|
||||
|
||||
localCallParticipantModelObserversOn.add(new LocalCallParticipantModelObserverOn(observer, handler));
|
||||
}
|
||||
|
||||
public synchronized void removeObserver(LocalCallParticipantModel.Observer observer) {
|
||||
Iterator<LocalCallParticipantModelObserverOn> it = localCallParticipantModelObserversOn.iterator();
|
||||
while (it.hasNext()) {
|
||||
LocalCallParticipantModelObserverOn observerOn = it.next();
|
||||
|
||||
if (observerOn.observer == observer) {
|
||||
it.remove();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void notifyChange() {
|
||||
for (LocalCallParticipantModelObserverOn observerOn : new ArrayList<>(localCallParticipantModelObserversOn)) {
|
||||
if (observerOn.handler == null || observerOn.handler.getLooper() == Looper.myLooper()) {
|
||||
observerOn.observer.onChange();
|
||||
} else {
|
||||
observerOn.handler.post(() -> {
|
||||
observerOn.observer.onChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import com.nextcloud.talk.models.json.signaling.DataChannelMessage;
|
||||
import com.nextcloud.talk.models.json.signaling.NCMessagePayload;
|
||||
import com.nextcloud.talk.models.json.signaling.NCSignalingMessage;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Helper class to send the local participant state to the other participants in the call.
|
||||
* <p>
|
||||
* Once created, and until destroyed, the LocalStateBroadcaster will send the changes in the local participant state to
|
||||
* all the participants in the call. Note that the LocalStateBroadcaster does not check whether the local participant
|
||||
* is actually in the call or not; it is expected that the LocalStateBroadcaster will be created and destroyed when the
|
||||
* local participant joins and leaves the call.
|
||||
* <p>
|
||||
* The LocalStateBroadcaster also sends the current state to remote participants when they join (which implicitly
|
||||
* sends it to all remote participants when the local participant joins the call) so they can set an initial state
|
||||
* for the local participant.
|
||||
*/
|
||||
public abstract class LocalStateBroadcaster {
|
||||
|
||||
private final LocalCallParticipantModel localCallParticipantModel;
|
||||
|
||||
private final LocalCallParticipantModelObserver localCallParticipantModelObserver;
|
||||
|
||||
private final MessageSender messageSender;
|
||||
|
||||
private class LocalCallParticipantModelObserver implements LocalCallParticipantModel.Observer {
|
||||
|
||||
private Boolean audioEnabled;
|
||||
private Boolean speaking;
|
||||
private Boolean videoEnabled;
|
||||
|
||||
public LocalCallParticipantModelObserver(LocalCallParticipantModel localCallParticipantModel) {
|
||||
audioEnabled = localCallParticipantModel.isAudioEnabled();
|
||||
speaking = localCallParticipantModel.isSpeaking();
|
||||
videoEnabled = localCallParticipantModel.isVideoEnabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChange() {
|
||||
if (!Objects.equals(audioEnabled, localCallParticipantModel.isAudioEnabled())) {
|
||||
audioEnabled = localCallParticipantModel.isAudioEnabled();
|
||||
|
||||
messageSender.sendToAll(getDataChannelMessageForAudioState());
|
||||
messageSender.sendToAll(getSignalingMessageForAudioState());
|
||||
}
|
||||
|
||||
if (!Objects.equals(speaking, localCallParticipantModel.isSpeaking())) {
|
||||
speaking = localCallParticipantModel.isSpeaking();
|
||||
|
||||
messageSender.sendToAll(getDataChannelMessageForSpeakingState());
|
||||
}
|
||||
|
||||
if (!Objects.equals(videoEnabled, localCallParticipantModel.isVideoEnabled())) {
|
||||
videoEnabled = localCallParticipantModel.isVideoEnabled();
|
||||
|
||||
messageSender.sendToAll(getDataChannelMessageForVideoState());
|
||||
messageSender.sendToAll(getSignalingMessageForVideoState());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public LocalStateBroadcaster(LocalCallParticipantModel localCallParticipantModel,
|
||||
MessageSender messageSender) {
|
||||
this.localCallParticipantModel = localCallParticipantModel;
|
||||
this.localCallParticipantModelObserver = new LocalCallParticipantModelObserver(localCallParticipantModel);
|
||||
this.messageSender = messageSender;
|
||||
|
||||
this.localCallParticipantModel.addObserver(localCallParticipantModelObserver);
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
this.localCallParticipantModel.removeObserver(localCallParticipantModelObserver);
|
||||
}
|
||||
|
||||
public abstract void handleCallParticipantAdded(CallParticipantModel callParticipantModel);
|
||||
public abstract void handleCallParticipantRemoved(CallParticipantModel callParticipantModel);
|
||||
|
||||
protected DataChannelMessage getDataChannelMessageForAudioState() {
|
||||
String type = "audioOff";
|
||||
if (localCallParticipantModel.isAudioEnabled() != null && localCallParticipantModel.isAudioEnabled()) {
|
||||
type = "audioOn";
|
||||
}
|
||||
|
||||
return new DataChannelMessage(type);
|
||||
}
|
||||
|
||||
protected DataChannelMessage getDataChannelMessageForSpeakingState() {
|
||||
String type = "stoppedSpeaking";
|
||||
if (localCallParticipantModel.isSpeaking() != null && localCallParticipantModel.isSpeaking()) {
|
||||
type = "speaking";
|
||||
}
|
||||
|
||||
return new DataChannelMessage(type);
|
||||
}
|
||||
|
||||
protected DataChannelMessage getDataChannelMessageForVideoState() {
|
||||
String type = "videoOff";
|
||||
if (localCallParticipantModel.isVideoEnabled() != null && localCallParticipantModel.isVideoEnabled()) {
|
||||
type = "videoOn";
|
||||
}
|
||||
|
||||
return new DataChannelMessage(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a signaling message with the common fields set (type and room type).
|
||||
*
|
||||
* @param type the type of the signaling message
|
||||
* @return the signaling message
|
||||
*/
|
||||
private NCSignalingMessage createBaseSignalingMessage(String type) {
|
||||
NCSignalingMessage ncSignalingMessage = new NCSignalingMessage();
|
||||
// "roomType" is not really relevant without a peer or when referring to the whole participant, but it is
|
||||
// nevertheless expected in the message. As most of the signaling messages currently sent to all participants
|
||||
// are related to audio/video state "video" is used as the room type.
|
||||
ncSignalingMessage.setRoomType("video");
|
||||
ncSignalingMessage.setType(type);
|
||||
|
||||
return ncSignalingMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a signaling message to notify current audio state.
|
||||
*
|
||||
* @return the signaling message
|
||||
*/
|
||||
protected NCSignalingMessage getSignalingMessageForAudioState() {
|
||||
String type = "mute";
|
||||
if (localCallParticipantModel.isAudioEnabled() != null && localCallParticipantModel.isAudioEnabled()) {
|
||||
type = "unmute";
|
||||
}
|
||||
|
||||
NCSignalingMessage ncSignalingMessage = createBaseSignalingMessage(type);
|
||||
|
||||
NCMessagePayload ncMessagePayload = new NCMessagePayload();
|
||||
ncMessagePayload.setName("audio");
|
||||
ncSignalingMessage.setPayload(ncMessagePayload);
|
||||
|
||||
return ncSignalingMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a signaling message to notify current video state.
|
||||
*
|
||||
* @return the signaling message
|
||||
*/
|
||||
protected NCSignalingMessage getSignalingMessageForVideoState() {
|
||||
String type = "mute";
|
||||
if (localCallParticipantModel.isVideoEnabled() != null && localCallParticipantModel.isVideoEnabled()) {
|
||||
type = "unmute";
|
||||
}
|
||||
|
||||
NCSignalingMessage ncSignalingMessage = createBaseSignalingMessage(type);
|
||||
|
||||
NCMessagePayload ncMessagePayload = new NCMessagePayload();
|
||||
ncMessagePayload.setName("video");
|
||||
ncSignalingMessage.setPayload(ncMessagePayload);
|
||||
|
||||
return ncSignalingMessage;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.Observable;
|
||||
import io.reactivex.disposables.Disposable;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
* Helper class to send the local participant state to the other participants in the call when an MCU is used.
|
||||
* <p>
|
||||
* Sending the state when it changes is handled by the base class; this subclass only handles sending the initial
|
||||
* state when a remote participant is added.
|
||||
* <p>
|
||||
* When Janus is used data channel messages are sent to all remote participants (with a peer connection to receive from
|
||||
* the local participant). Moreover, it is not possible to know when the remote participants open the data channel to
|
||||
* receive the messages, or even when they establish the receiver connection; it is only possible to know when the
|
||||
* data channel is open for the publisher connection of the local participant. Due to all that the state is sent
|
||||
* several times with an increasing delay whenever a participant joins the call (which implicitly broadcasts the
|
||||
* initial state when the local participant joins the call, as all the remote participants joined from the point of
|
||||
* view of the local participant). If the state was already being sent the sending is restarted with each new
|
||||
* participant that joins.
|
||||
* <p>
|
||||
* Similarly, in the case of signaling messages it is not possible either to know when the remote participants have
|
||||
* "seen" the local participant and thus are ready to handle signaling messages about the state. However, in the case
|
||||
* of signaling messages it is possible to send them to a specific participant, so the initial state is sent several
|
||||
* times with an increasing delay directly to the participant that was added. Moreover, if the participant is removed
|
||||
* the state is no longer directly sent.
|
||||
* <p>
|
||||
* In any case, note that the state is sent only when the remote participant joins the call. Even in case of
|
||||
* temporary disconnections the normal state updates sent when the state changes are expected to be received by the
|
||||
* other participant, as signaling messages are sent through a WebSocket and are therefore reliable. Moreover, even
|
||||
* if the WebSocket is restarted and the connection resumed (rather than joining with a new session ID) the messages
|
||||
* would be also received, as in that case they would be queued until the WebSocket is connected again.
|
||||
* <p>
|
||||
* Data channel messages, on the other hand, could be lost if the remote participant restarts the peer receiver
|
||||
* connection (although they would be received in case of temporary disconnections, as data channels use a reliable
|
||||
* transport by default). Therefore, as the speaking state is sent only through data channels, updates of the speaking
|
||||
* state could be not received by remote participants.
|
||||
*/
|
||||
public class LocalStateBroadcasterMcu extends LocalStateBroadcaster {
|
||||
|
||||
private final MessageSender messageSender;
|
||||
|
||||
private final Map<String, Disposable> sendStateWithRepetitionByParticipant = new HashMap<>();
|
||||
|
||||
private Disposable sendStateWithRepetition;
|
||||
|
||||
public LocalStateBroadcasterMcu(LocalCallParticipantModel localCallParticipantModel,
|
||||
MessageSender messageSender) {
|
||||
super(localCallParticipantModel, messageSender);
|
||||
|
||||
this.messageSender = messageSender;
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
super.destroy();
|
||||
|
||||
if (sendStateWithRepetition != null) {
|
||||
sendStateWithRepetition.dispose();
|
||||
}
|
||||
|
||||
for (Disposable sendStateWithRepetitionForParticipant: sendStateWithRepetitionByParticipant.values()) {
|
||||
sendStateWithRepetitionForParticipant.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCallParticipantAdded(CallParticipantModel callParticipantModel) {
|
||||
if (sendStateWithRepetition != null) {
|
||||
sendStateWithRepetition.dispose();
|
||||
}
|
||||
|
||||
sendStateWithRepetition = Observable
|
||||
.fromArray(new Integer[]{0, 1, 2, 4, 8, 16})
|
||||
.concatMap(i -> Observable.just(i).delay(i, TimeUnit.SECONDS, Schedulers.io()))
|
||||
.subscribe(value -> sendState());
|
||||
|
||||
String sessionId = callParticipantModel.getSessionId();
|
||||
Disposable sendStateWithRepetitionForParticipant = sendStateWithRepetitionByParticipant.get(sessionId);
|
||||
if (sendStateWithRepetitionForParticipant != null) {
|
||||
sendStateWithRepetitionForParticipant.dispose();
|
||||
}
|
||||
|
||||
sendStateWithRepetitionByParticipant.put(sessionId, Observable
|
||||
.fromArray(new Integer[]{0, 1, 2, 4, 8, 16})
|
||||
.concatMap(i -> Observable.just(i).delay(i, TimeUnit.SECONDS, Schedulers.io()))
|
||||
.subscribe(value -> sendState(sessionId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCallParticipantRemoved(CallParticipantModel callParticipantModel) {
|
||||
String sessionId = callParticipantModel.getSessionId();
|
||||
Disposable sendStateWithRepetitionForParticipant = sendStateWithRepetitionByParticipant.get(sessionId);
|
||||
if (sendStateWithRepetitionForParticipant != null) {
|
||||
sendStateWithRepetitionForParticipant.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void sendState() {
|
||||
messageSender.sendToAll(getDataChannelMessageForAudioState());
|
||||
messageSender.sendToAll(getDataChannelMessageForSpeakingState());
|
||||
messageSender.sendToAll(getDataChannelMessageForVideoState());
|
||||
}
|
||||
|
||||
private void sendState(String sessionId) {
|
||||
messageSender.send(getSignalingMessageForAudioState(), sessionId);
|
||||
messageSender.send(getSignalingMessageForVideoState(), sessionId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import org.webrtc.PeerConnection;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Helper class to send the local participant state to the other participants in the call when an MCU is not used.
|
||||
* <p>
|
||||
* Sending the state when it changes is handled by the base class; this subclass only handles sending the initial
|
||||
* state when a remote participant is added.
|
||||
* <p>
|
||||
* The state is sent when a connection with another participant is first established (which implicitly broadcasts the
|
||||
* initial state when the local participant joins the call, as a connection is established with all the remote
|
||||
* participants). Note that, as long as that participant stays in the call, the initial state is not sent again, even
|
||||
* after a temporary disconnection; data channels use a reliable transport by default, so even if the state changes
|
||||
* while the connection is temporarily interrupted the normal state update messages should be received by the other
|
||||
* participant once the connection is restored.
|
||||
* <p>
|
||||
* Nevertheless, in case of a failed connection and an ICE restart it is unclear whether the data channel messages
|
||||
* would be received or not (as the data channel transport may be the one that failed and needs to be restarted).
|
||||
* However, the state (except the speaking state) is also sent through signaling messages, which need to be
|
||||
* explicitly fetched from the internal signaling server, so even in case of a failed connection they will be
|
||||
* eventually received once the remote participant connects again.
|
||||
*/
|
||||
public class LocalStateBroadcasterNoMcu extends LocalStateBroadcaster {
|
||||
|
||||
private final MessageSenderNoMcu messageSender;
|
||||
|
||||
private final Map<String, IceConnectionStateObserver> iceConnectionStateObservers = new HashMap<>();
|
||||
|
||||
private class IceConnectionStateObserver implements CallParticipantModel.Observer {
|
||||
|
||||
private final CallParticipantModel callParticipantModel;
|
||||
|
||||
private PeerConnection.IceConnectionState iceConnectionState;
|
||||
|
||||
public IceConnectionStateObserver(CallParticipantModel callParticipantModel) {
|
||||
this.callParticipantModel = callParticipantModel;
|
||||
|
||||
callParticipantModel.addObserver(this);
|
||||
iceConnectionStateObservers.put(callParticipantModel.getSessionId(), this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChange() {
|
||||
if (Objects.equals(iceConnectionState, callParticipantModel.getIceConnectionState())) {
|
||||
return;
|
||||
}
|
||||
|
||||
iceConnectionState = callParticipantModel.getIceConnectionState();
|
||||
|
||||
if (iceConnectionState == PeerConnection.IceConnectionState.CONNECTED ||
|
||||
iceConnectionState == PeerConnection.IceConnectionState.COMPLETED) {
|
||||
remove();
|
||||
|
||||
sendState(callParticipantModel.getSessionId());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReaction(String reaction) {
|
||||
}
|
||||
|
||||
public void remove() {
|
||||
callParticipantModel.removeObserver(this);
|
||||
iceConnectionStateObservers.remove(callParticipantModel.getSessionId());
|
||||
}
|
||||
}
|
||||
|
||||
public LocalStateBroadcasterNoMcu(LocalCallParticipantModel localCallParticipantModel,
|
||||
MessageSenderNoMcu messageSender) {
|
||||
super(localCallParticipantModel, messageSender);
|
||||
|
||||
this.messageSender = messageSender;
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
super.destroy();
|
||||
|
||||
// The observers remove themselves from the map, so a copy is needed to remove them while iterating.
|
||||
List<IceConnectionStateObserver> iceConnectionStateObserversCopy =
|
||||
new ArrayList<>(iceConnectionStateObservers.values());
|
||||
for (IceConnectionStateObserver iceConnectionStateObserver : iceConnectionStateObserversCopy) {
|
||||
iceConnectionStateObserver.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCallParticipantAdded(CallParticipantModel callParticipantModel) {
|
||||
IceConnectionStateObserver iceConnectionStateObserver =
|
||||
iceConnectionStateObservers.get(callParticipantModel.getSessionId());
|
||||
if (iceConnectionStateObserver != null) {
|
||||
iceConnectionStateObserver.remove();
|
||||
}
|
||||
|
||||
iceConnectionStateObserver = new IceConnectionStateObserver(callParticipantModel);
|
||||
iceConnectionStateObservers.put(callParticipantModel.getSessionId(), iceConnectionStateObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCallParticipantRemoved(CallParticipantModel callParticipantModel) {
|
||||
IceConnectionStateObserver iceConnectionStateObserver =
|
||||
iceConnectionStateObservers.get(callParticipantModel.getSessionId());
|
||||
if (iceConnectionStateObserver != null) {
|
||||
iceConnectionStateObserver.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private void sendState(String sessionId) {
|
||||
messageSender.send(getDataChannelMessageForAudioState(), sessionId);
|
||||
messageSender.send(getDataChannelMessageForSpeakingState(), sessionId);
|
||||
messageSender.send(getDataChannelMessageForVideoState(), sessionId);
|
||||
|
||||
messageSender.send(getSignalingMessageForAudioState(), sessionId);
|
||||
messageSender.send(getSignalingMessageForVideoState(), sessionId);
|
||||
}
|
||||
}
|
||||
93
app/src/main/java/com/nextcloud/talk/call/MessageSender.java
Normal file
93
app/src/main/java/com/nextcloud/talk/call/MessageSender.java
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import com.nextcloud.talk.models.json.signaling.DataChannelMessage;
|
||||
import com.nextcloud.talk.models.json.signaling.NCSignalingMessage;
|
||||
import com.nextcloud.talk.signaling.SignalingMessageSender;
|
||||
import com.nextcloud.talk.webrtc.PeerConnectionWrapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Helper class to send messages to participants in a call.
|
||||
* <p>
|
||||
* A specific subclass has to be created depending on whether an MCU is being used or not.
|
||||
* <p>
|
||||
* Note that recipients of signaling messages are not validated, so no error will be triggered if trying to send a
|
||||
* message to a participant with a session ID that does not exist or is not in the call.
|
||||
* <p>
|
||||
* Also note that, unlike signaling messages, data channel messages require a peer connection. Therefore data channel
|
||||
* messages may not be received by a participant if there is no peer connection with that participant (for example, if
|
||||
* neither the local and remote participants have publishing rights). Moreover, data channel messages are expected to
|
||||
* be received only on peer connections with type "video", so data channel messages will not be sent on other peer
|
||||
* connections.
|
||||
*/
|
||||
public abstract class MessageSender {
|
||||
|
||||
private final SignalingMessageSender signalingMessageSender;
|
||||
|
||||
private final Set<String> callParticipantSessionIds;
|
||||
|
||||
protected final List<PeerConnectionWrapper> peerConnectionWrappers;
|
||||
|
||||
public MessageSender(SignalingMessageSender signalingMessageSender,
|
||||
Set<String> callParticipantSessionIds,
|
||||
List<PeerConnectionWrapper> peerConnectionWrappers) {
|
||||
this.signalingMessageSender = signalingMessageSender;
|
||||
this.callParticipantSessionIds = callParticipantSessionIds;
|
||||
this.peerConnectionWrappers = peerConnectionWrappers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the given data channel message to all the participants in the call.
|
||||
*
|
||||
* @param dataChannelMessage the message to send
|
||||
*/
|
||||
public abstract void sendToAll(DataChannelMessage dataChannelMessage);
|
||||
|
||||
/**
|
||||
* Sends the given signaling message to the given session ID.
|
||||
* <p>
|
||||
* Note that the signaling message will be modified to set the recipient in the "to" field.
|
||||
*
|
||||
* @param ncSignalingMessage the message to send
|
||||
* @param sessionId the signaling session ID of the participant to send the message to
|
||||
*/
|
||||
public void send(NCSignalingMessage ncSignalingMessage, String sessionId) {
|
||||
ncSignalingMessage.setTo(sessionId);
|
||||
|
||||
signalingMessageSender.send(ncSignalingMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the given signaling message to all the participants in the call.
|
||||
* <p>
|
||||
* Note that the signaling message will be modified to set each of the recipients in the "to" field.
|
||||
*
|
||||
* @param ncSignalingMessage the message to send
|
||||
*/
|
||||
public void sendToAll(NCSignalingMessage ncSignalingMessage) {
|
||||
for (String sessionId: callParticipantSessionIds) {
|
||||
ncSignalingMessage.setTo(sessionId);
|
||||
|
||||
signalingMessageSender.send(ncSignalingMessage);
|
||||
}
|
||||
}
|
||||
|
||||
protected PeerConnectionWrapper getPeerConnectionWrapper(String sessionId) {
|
||||
for (PeerConnectionWrapper peerConnectionWrapper: peerConnectionWrappers) {
|
||||
if (peerConnectionWrapper.getSessionId().equals(sessionId)
|
||||
&& "video".equals(peerConnectionWrapper.getVideoStreamType())) {
|
||||
return peerConnectionWrapper;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import com.nextcloud.talk.models.json.signaling.DataChannelMessage;
|
||||
import com.nextcloud.talk.signaling.SignalingMessageSender;
|
||||
import com.nextcloud.talk.webrtc.PeerConnectionWrapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Helper class to send messages to participants in a call when an MCU is used.
|
||||
* <p>
|
||||
* Note that when Janus is used it is not possible to send a data channel message to a specific participant. Any data
|
||||
* channel message will be broadcast to all the subscribers of the publisher peer connection (the own peer connection).
|
||||
*/
|
||||
public class MessageSenderMcu extends MessageSender {
|
||||
|
||||
private final String ownSessionId;
|
||||
|
||||
public MessageSenderMcu(SignalingMessageSender signalingMessageSender,
|
||||
Set<String> callParticipantSessionIds,
|
||||
List<PeerConnectionWrapper> peerConnectionWrappers,
|
||||
String ownSessionId) {
|
||||
super(signalingMessageSender, callParticipantSessionIds, peerConnectionWrappers);
|
||||
|
||||
this.ownSessionId = ownSessionId;
|
||||
}
|
||||
|
||||
public void sendToAll(DataChannelMessage dataChannelMessage) {
|
||||
PeerConnectionWrapper ownPeerConnectionWrapper = getPeerConnectionWrapper(ownSessionId);
|
||||
if (ownPeerConnectionWrapper != null) {
|
||||
ownPeerConnectionWrapper.send(dataChannelMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import com.nextcloud.talk.models.json.signaling.DataChannelMessage;
|
||||
import com.nextcloud.talk.signaling.SignalingMessageSender;
|
||||
import com.nextcloud.talk.webrtc.PeerConnectionWrapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Helper class to send messages to participants in a call when an MCU is not used.
|
||||
*/
|
||||
public class MessageSenderNoMcu extends MessageSender {
|
||||
|
||||
public MessageSenderNoMcu(SignalingMessageSender signalingMessageSender,
|
||||
Set<String> callParticipantSessionIds,
|
||||
List<PeerConnectionWrapper> peerConnectionWrappers) {
|
||||
super(signalingMessageSender, callParticipantSessionIds, peerConnectionWrappers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the given data channel message to the given signaling session ID.
|
||||
*
|
||||
* @param dataChannelMessage the message to send
|
||||
* @param sessionId the signaling session ID of the participant to send the message to
|
||||
*/
|
||||
public void send(DataChannelMessage dataChannelMessage, String sessionId) {
|
||||
PeerConnectionWrapper peerConnectionWrapper = getPeerConnectionWrapper(sessionId);
|
||||
if (peerConnectionWrapper != null) {
|
||||
peerConnectionWrapper.send(dataChannelMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public void sendToAll(DataChannelMessage dataChannelMessage) {
|
||||
for (PeerConnectionWrapper peerConnectionWrapper: peerConnectionWrappers) {
|
||||
if ("video".equals(peerConnectionWrapper.getVideoStreamType())){
|
||||
peerConnectionWrapper.send(dataChannelMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import com.nextcloud.talk.models.json.participants.Participant;
|
||||
|
||||
import org.webrtc.MediaStream;
|
||||
import org.webrtc.PeerConnection;
|
||||
|
||||
/**
|
||||
* Mutable data model for (remote) call participants.
|
||||
* <p>
|
||||
* There is no synchronization when setting the values; if needed, it should be handled by the clients of the model.
|
||||
*/
|
||||
public class MutableCallParticipantModel extends CallParticipantModel {
|
||||
|
||||
public MutableCallParticipantModel(String sessionId) {
|
||||
super(sessionId);
|
||||
}
|
||||
|
||||
public void setActor(Participant.ActorType actorType, String actorId) {
|
||||
this.actorType.setValue(actorType);
|
||||
this.actorId.setValue(actorId);
|
||||
}
|
||||
|
||||
public void setUserId(String userId) {
|
||||
this.userId.setValue(userId);
|
||||
}
|
||||
|
||||
public void setNick(String nick) {
|
||||
this.nick.setValue(nick);
|
||||
}
|
||||
|
||||
public void setInternal(Boolean internal) {
|
||||
this.internal.setValue(internal);
|
||||
}
|
||||
|
||||
public void setRaisedHand(boolean state, long timestamp) {
|
||||
this.raisedHand.setValue(new RaisedHand(state, timestamp));
|
||||
}
|
||||
|
||||
public void setIceConnectionState(PeerConnection.IceConnectionState iceConnectionState) {
|
||||
this.iceConnectionState.setValue(iceConnectionState);
|
||||
}
|
||||
|
||||
public void setMediaStream(MediaStream mediaStream) {
|
||||
this.mediaStream.setValue(mediaStream);
|
||||
}
|
||||
|
||||
public void setAudioAvailable(Boolean audioAvailable) {
|
||||
this.audioAvailable.setValue(audioAvailable);
|
||||
}
|
||||
|
||||
public void setVideoAvailable(Boolean videoAvailable) {
|
||||
this.videoAvailable.setValue(videoAvailable);
|
||||
}
|
||||
|
||||
public void setScreenIceConnectionState(PeerConnection.IceConnectionState screenIceConnectionState) {
|
||||
this.screenIceConnectionState.setValue(screenIceConnectionState);
|
||||
}
|
||||
|
||||
public void setScreenMediaStream(MediaStream screenMediaStream) {
|
||||
this.screenMediaStream.setValue(screenMediaStream);
|
||||
}
|
||||
|
||||
public void emitReaction(String reaction) {
|
||||
this.callParticipantModelNotifier.notifyReaction(reaction);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Mutable data model for local call participants.
|
||||
* <p>
|
||||
* Setting "speaking" will automatically set "speaking" or "speakingWhileMuted" as needed, depending on whether audio is
|
||||
* enabled or not. Similarly, setting whether the audio is enabled or disabled will automatically switch between
|
||||
* "speaking" and "speakingWhileMuted" as needed.
|
||||
* <p>
|
||||
* There is no synchronization when setting the values; if needed, it should be handled by the clients of the model.
|
||||
*/
|
||||
public class MutableLocalCallParticipantModel extends LocalCallParticipantModel {
|
||||
|
||||
public void setAudioEnabled(Boolean audioEnabled) {
|
||||
if (Objects.equals(this.audioEnabled.getValue(), audioEnabled)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioEnabled == null || !audioEnabled) {
|
||||
this.speakingWhileMuted.setValue(this.speaking.getValue());
|
||||
this.speaking.setValue(Boolean.FALSE);
|
||||
}
|
||||
|
||||
this.audioEnabled.setValue(audioEnabled);
|
||||
|
||||
if (audioEnabled != null && audioEnabled) {
|
||||
this.speaking.setValue(this.speakingWhileMuted.getValue());
|
||||
this.speakingWhileMuted.setValue(Boolean.FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
public void setSpeaking(Boolean speaking) {
|
||||
if (this.audioEnabled.getValue() != null && this.audioEnabled.getValue()) {
|
||||
this.speaking.setValue(speaking);
|
||||
} else {
|
||||
this.speakingWhileMuted.setValue(speaking);
|
||||
}
|
||||
}
|
||||
|
||||
public void setVideoEnabled(Boolean videoEnabled) {
|
||||
this.videoEnabled.setValue(videoEnabled);
|
||||
}
|
||||
}
|
||||
9
app/src/main/java/com/nextcloud/talk/call/RaisedHand.kt
Normal file
9
app/src/main/java/com/nextcloud/talk/call/RaisedHand.kt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2023 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call
|
||||
|
||||
data class RaisedHand(val state: Boolean, val timestamp: Long)
|
||||
166
app/src/main/java/com/nextcloud/talk/call/ReactionAnimator.kt
Normal file
166
app/src/main/java/com/nextcloud/talk/call/ReactionAnimator.kt
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.call
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.AnimatorSet
|
||||
import android.animation.ObjectAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.vanniktech.emoji.EmojiTextView
|
||||
|
||||
class ReactionAnimator(
|
||||
val context: Context,
|
||||
private val startPointView: RelativeLayout,
|
||||
val viewThemeUtils: ViewThemeUtils?
|
||||
) {
|
||||
private val reactionsList: MutableList<CallReaction> = ArrayList()
|
||||
|
||||
fun addReaction(emoji: String, displayName: String) {
|
||||
val callReaction = CallReaction(emoji, displayName)
|
||||
reactionsList.add(callReaction)
|
||||
|
||||
if (reactionsList.size == 1) {
|
||||
animateReaction(reactionsList[0])
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateReaction(callReaction: CallReaction) {
|
||||
val reactionWrapper = getReactionWrapperView(callReaction)
|
||||
|
||||
val params = RelativeLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
leftMargin = 0
|
||||
bottomMargin = 0
|
||||
}
|
||||
|
||||
params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM, 1)
|
||||
startPointView.addView(reactionWrapper, params)
|
||||
|
||||
val moveWithFullAlpha = ObjectAnimator.ofFloat(
|
||||
reactionWrapper,
|
||||
TRANSLATION_Y_PROPERTY,
|
||||
POSITION_Y_WITH_FULL_ALPHA
|
||||
)
|
||||
moveWithFullAlpha.duration = DURATION_FULL_ALPHA
|
||||
moveWithFullAlpha.interpolator = LinearInterpolator()
|
||||
|
||||
val moveWithDecreasingAlpha = ObjectAnimator.ofFloat(
|
||||
reactionWrapper,
|
||||
TRANSLATION_Y_PROPERTY,
|
||||
POSITION_Y_WITH_DECREASING_ALPHA
|
||||
)
|
||||
moveWithDecreasingAlpha.duration = DURATION_DECREASING_ALPHA
|
||||
moveWithDecreasingAlpha.interpolator = LinearInterpolator()
|
||||
|
||||
val decreasingAlpha: ObjectAnimator = ObjectAnimator.ofFloat(
|
||||
reactionWrapper,
|
||||
ALPHA_PROPERTY,
|
||||
ZERO_ALPHA
|
||||
)
|
||||
decreasingAlpha.duration = DURATION_DECREASING_ALPHA
|
||||
|
||||
val animatorWithFullAlpha = AnimatorSet()
|
||||
animatorWithFullAlpha.play(moveWithFullAlpha)
|
||||
|
||||
animatorWithFullAlpha.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
reactionsList.remove(callReaction)
|
||||
if (reactionsList.isNotEmpty()) {
|
||||
animateReaction(reactionsList[0])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
val animatorWithDecreasingAlpha = AnimatorSet()
|
||||
animatorWithDecreasingAlpha.playTogether(moveWithDecreasingAlpha, decreasingAlpha)
|
||||
|
||||
val finalAnimator = AnimatorSet()
|
||||
finalAnimator.play(animatorWithFullAlpha).before(animatorWithDecreasingAlpha)
|
||||
|
||||
finalAnimator.start()
|
||||
}
|
||||
|
||||
private fun getReactionWrapperView(callReaction: CallReaction): LinearLayout {
|
||||
val reactionWrapper = LinearLayout(context)
|
||||
reactionWrapper.orientation = LinearLayout.HORIZONTAL
|
||||
|
||||
val emojiView = EmojiTextView(context)
|
||||
emojiView.text = callReaction.emoji
|
||||
emojiView.textSize = TEXT_SIZE
|
||||
|
||||
val nameView = getNameView(callReaction)
|
||||
reactionWrapper.addView(emojiView)
|
||||
reactionWrapper.addView(nameView)
|
||||
return reactionWrapper
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private fun getNameView(callReaction: CallReaction): TextView {
|
||||
val nameView = TextView(context)
|
||||
|
||||
val nameViewParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
|
||||
nameViewParams.setMargins(HORIZONTAL_MARGIN, 0, HORIZONTAL_MARGIN, BOTTOM_MARGIN)
|
||||
nameView.layoutParams = nameViewParams
|
||||
|
||||
nameView.text = " " + callReaction.userName + " "
|
||||
nameView.setTextColor(context.resources.getColor(R.color.white, null))
|
||||
|
||||
val backgroundColor = ContextCompat.getColor(
|
||||
context,
|
||||
R.color.colorPrimary
|
||||
)
|
||||
|
||||
val drawable = AppCompatResources
|
||||
.getDrawable(context, R.drawable.reaction_self_background)!!
|
||||
.mutate()
|
||||
DrawableCompat.setTintList(
|
||||
drawable,
|
||||
ColorStateList.valueOf(backgroundColor)
|
||||
)
|
||||
nameView.background = drawable
|
||||
return nameView
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TRANSLATION_Y_PROPERTY = "translationY"
|
||||
|
||||
// 1333ms to move emoji up 400px with full alpha
|
||||
private const val DURATION_FULL_ALPHA = 1333L
|
||||
private const val POSITION_Y_WITH_FULL_ALPHA = -400f
|
||||
|
||||
// 666ms to move emoji up 200px while decreasing alpha
|
||||
private const val DURATION_DECREASING_ALPHA = 666L
|
||||
private const val POSITION_Y_WITH_DECREASING_ALPHA = -600f
|
||||
|
||||
private const val ZERO_ALPHA = 0f
|
||||
private const val ALPHA_PROPERTY = "alpha"
|
||||
|
||||
private const val TEXT_SIZE = 20f
|
||||
private const val HORIZONTAL_MARGIN: Int = 20
|
||||
private const val BOTTOM_MARGIN: Int = 5
|
||||
}
|
||||
}
|
||||
data class CallReaction(var emoji: String, var userName: String)
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.call.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.nextcloud.talk.adapters.ParticipantUiState
|
||||
|
||||
@Composable
|
||||
fun AvatarWithFallback(participant: ParticipantUiState, modifier: Modifier = Modifier) {
|
||||
val initials = participant.nick
|
||||
.split(" ")
|
||||
.mapNotNull { it.firstOrNull()?.uppercase() }
|
||||
.take(2)
|
||||
.joinToString("")
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (!participant.avatarUrl.isNullOrEmpty()) {
|
||||
AsyncImage(
|
||||
model = participant.avatarUrl,
|
||||
contentDescription = "Avatar",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = initials.ifEmpty { "?" },
|
||||
color = Color.Black,
|
||||
fontSize = 24.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
@file:Suppress("MagicNumber", "TooManyFunctions")
|
||||
|
||||
package com.nextcloud.talk.call.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nextcloud.talk.adapters.ParticipantUiState
|
||||
import org.webrtc.EglBase
|
||||
import kotlin.math.ceil
|
||||
|
||||
@SuppressLint("UnusedBoxWithConstraintsScope")
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
fun ParticipantGrid(
|
||||
modifier: Modifier = Modifier,
|
||||
eglBase: EglBase?,
|
||||
participantUiStates: List<ParticipantUiState>,
|
||||
isVoiceOnlyCall: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT
|
||||
|
||||
val minItemHeight = 100.dp
|
||||
|
||||
if (participantUiStates.isEmpty()) return
|
||||
|
||||
val columns = if (isPortrait) {
|
||||
when (participantUiStates.size) {
|
||||
1, 2, 3 -> 1
|
||||
else -> 2
|
||||
}
|
||||
} else {
|
||||
when (participantUiStates.size) {
|
||||
1 -> 1
|
||||
2, 4 -> 2
|
||||
else -> 3
|
||||
}
|
||||
}.coerceAtLeast(1) // Prevent 0
|
||||
|
||||
val rows = ceil(participantUiStates.size / columns.toFloat()).toInt().coerceAtLeast(1)
|
||||
|
||||
val itemSpacing = 8.dp
|
||||
val edgePadding = 8.dp
|
||||
val totalVerticalSpacing = itemSpacing * (rows - 1)
|
||||
val totalVerticalPadding = edgePadding * 2
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.clickable { onClick() }
|
||||
) {
|
||||
val availableHeight = maxHeight
|
||||
|
||||
val gridAvailableHeight = availableHeight - totalVerticalSpacing - totalVerticalPadding
|
||||
val rawItemHeight = gridAvailableHeight / rows
|
||||
val itemHeight = maxOf(rawItemHeight, minItemHeight)
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(availableHeight),
|
||||
verticalArrangement = Arrangement.spacedBy(itemSpacing),
|
||||
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
|
||||
contentPadding = PaddingValues(vertical = edgePadding, horizontal = edgePadding)
|
||||
) {
|
||||
items(
|
||||
participantUiStates,
|
||||
key = { it.sessionKey }
|
||||
) { participant ->
|
||||
ParticipantTile(
|
||||
participantUiState = participant,
|
||||
modifier = Modifier
|
||||
.height(itemHeight)
|
||||
.fillMaxWidth(),
|
||||
eglBase = eglBase,
|
||||
isVoiceOnlyCall = isVoiceOnlyCall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ParticipantGridPreview() {
|
||||
ParticipantGrid(
|
||||
participantUiStates = getTestParticipants(1),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun TwoParticipants() {
|
||||
ParticipantGrid(
|
||||
participantUiStates = getTestParticipants(2),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ThreeParticipants() {
|
||||
ParticipantGrid(
|
||||
participantUiStates = getTestParticipants(3),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun FourParticipants() {
|
||||
ParticipantGrid(
|
||||
participantUiStates = getTestParticipants(4),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun FiveParticipants() {
|
||||
ParticipantGrid(
|
||||
participantUiStates = getTestParticipants(5),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SevenParticipants() {
|
||||
ParticipantGrid(
|
||||
participantUiStates = getTestParticipants(7),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun FiftyParticipants() {
|
||||
ParticipantGrid(
|
||||
participantUiStates = getTestParticipants(50),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview(
|
||||
showBackground = false,
|
||||
heightDp = 360,
|
||||
widthDp = 800
|
||||
)
|
||||
@Composable
|
||||
fun OneParticipantLandscape() {
|
||||
ParticipantGrid(
|
||||
participantUiStates = getTestParticipants(1),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview(
|
||||
showBackground = false,
|
||||
heightDp = 360,
|
||||
widthDp = 800
|
||||
)
|
||||
@Composable
|
||||
fun TwoParticipantsLandscape() {
|
||||
ParticipantGrid(
|
||||
participantUiStates = getTestParticipants(2),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview(
|
||||
showBackground = false,
|
||||
heightDp = 360,
|
||||
widthDp = 800
|
||||
)
|
||||
@Composable
|
||||
fun ThreeParticipantsLandscape() {
|
||||
ParticipantGrid(
|
||||
participantUiStates = getTestParticipants(3),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview(
|
||||
showBackground = false,
|
||||
heightDp = 360,
|
||||
widthDp = 800
|
||||
)
|
||||
@Composable
|
||||
fun FourParticipantsLandscape() {
|
||||
ParticipantGrid(
|
||||
participantUiStates = getTestParticipants(4),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview(
|
||||
showBackground = false,
|
||||
heightDp = 360,
|
||||
widthDp = 800
|
||||
)
|
||||
@Composable
|
||||
fun SevenParticipantsLandscape() {
|
||||
ParticipantGrid(
|
||||
participantUiStates = getTestParticipants(7),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview(
|
||||
showBackground = false,
|
||||
heightDp = 360,
|
||||
widthDp = 800
|
||||
)
|
||||
@Composable
|
||||
fun FiftyParticipantsLandscape() {
|
||||
ParticipantGrid(
|
||||
participantUiStates = getTestParticipants(50),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
) {}
|
||||
}
|
||||
|
||||
fun getTestParticipants(numberOfParticipants: Int): List<ParticipantUiState> {
|
||||
val participantList = mutableListOf<ParticipantUiState>()
|
||||
for (i: Int in 1..numberOfParticipants) {
|
||||
val participant = ParticipantUiState(
|
||||
sessionKey = i.toString(),
|
||||
nick = "test$i user",
|
||||
isConnected = true,
|
||||
isAudioEnabled = false,
|
||||
isStreamEnabled = true,
|
||||
raisedHand = true,
|
||||
avatarUrl = "",
|
||||
mediaStream = null
|
||||
)
|
||||
participantList.add(participant)
|
||||
}
|
||||
return participantList
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.call.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.min
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.adapters.ParticipantUiState
|
||||
import com.nextcloud.talk.utils.ColorGenerator
|
||||
import org.webrtc.EglBase
|
||||
|
||||
const val NICK_OFFSET = 4f
|
||||
const val NICK_BLUR_RADIUS = 4f
|
||||
const val AVATAR_SIZE_FACTOR = 0.6f
|
||||
|
||||
@SuppressLint("UnusedBoxWithConstraintsScope")
|
||||
@Suppress("Detekt.LongMethod")
|
||||
@Composable
|
||||
fun ParticipantTile(
|
||||
participantUiState: ParticipantUiState,
|
||||
eglBase: EglBase?,
|
||||
modifier: Modifier = Modifier,
|
||||
isVoiceOnlyCall: Boolean
|
||||
) {
|
||||
val colorInt = ColorGenerator.usernameToColor(participantUiState.nick)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(colorInt))
|
||||
) {
|
||||
val avatarSize = min(maxWidth, maxHeight) * AVATAR_SIZE_FACTOR
|
||||
|
||||
if (!isVoiceOnlyCall && participantUiState.isStreamEnabled && participantUiState.mediaStream != null) {
|
||||
WebRTCVideoView(participantUiState, eglBase)
|
||||
} else {
|
||||
AvatarWithFallback(
|
||||
participant = participantUiState,
|
||||
modifier = Modifier
|
||||
.size(avatarSize)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
if (participantUiState.raisedHand) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_hand_back_left),
|
||||
contentDescription = "Raised Hand",
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(6.dp)
|
||||
.size(24.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
if (!participantUiState.isAudioEnabled) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_mic_off_white_24px),
|
||||
contentDescription = "Mic Off",
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(6.dp)
|
||||
.size(24.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = participantUiState.nick,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
shadow = Shadow(
|
||||
color = Color.Black,
|
||||
offset = Offset(NICK_OFFSET, NICK_OFFSET),
|
||||
blurRadius = NICK_BLUR_RADIUS
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (!participantUiState.isConnected) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = false)
|
||||
@Composable
|
||||
fun ParticipantTilePreview() {
|
||||
val participant = ParticipantUiState(
|
||||
sessionKey = "",
|
||||
nick = "testuser one",
|
||||
isConnected = true,
|
||||
isAudioEnabled = false,
|
||||
isStreamEnabled = true,
|
||||
raisedHand = true,
|
||||
avatarUrl = "",
|
||||
mediaStream = null
|
||||
)
|
||||
ParticipantTile(
|
||||
participantUiState = participant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
eglBase = null,
|
||||
isVoiceOnlyCall = false
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.call.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.nextcloud.talk.adapters.ParticipantUiState
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.SurfaceViewRenderer
|
||||
|
||||
@Composable
|
||||
fun WebRTCVideoView(participant: ParticipantUiState, eglBase: EglBase?) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
SurfaceViewRenderer(context).apply {
|
||||
init(eglBase?.eglBaseContext, null)
|
||||
setEnableHardwareScaler(true)
|
||||
setMirror(false)
|
||||
participant.mediaStream?.videoTracks?.firstOrNull()?.addSink(this)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onRelease = {
|
||||
participant.mediaStream?.videoTracks?.firstOrNull()?.removeSink(it)
|
||||
it.release()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.callbacks;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.Editable;
|
||||
import android.text.Spanned;
|
||||
import android.widget.EditText;
|
||||
|
||||
import com.nextcloud.talk.R;
|
||||
import com.nextcloud.talk.data.user.model.User;
|
||||
import com.nextcloud.talk.models.json.mention.Mention;
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils;
|
||||
import com.nextcloud.talk.utils.DisplayUtils;
|
||||
import com.nextcloud.talk.utils.CharPolicy;
|
||||
import com.nextcloud.talk.utils.text.Spans;
|
||||
import com.otaliastudios.autocomplete.AutocompleteCallback;
|
||||
import com.vanniktech.emoji.EmojiRange;
|
||||
import com.vanniktech.emoji.Emojis;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import kotlin.OptIn;
|
||||
import third.parties.fresco.BetterImageSpan;
|
||||
|
||||
public class MentionAutocompleteCallback implements AutocompleteCallback<Mention> {
|
||||
private final ViewThemeUtils viewThemeUtils;
|
||||
private Context context;
|
||||
private User conversationUser;
|
||||
private EditText editText;
|
||||
|
||||
public MentionAutocompleteCallback(Context context,
|
||||
User conversationUser,
|
||||
EditText editText,
|
||||
ViewThemeUtils viewThemeUtils) {
|
||||
this.context = context;
|
||||
this.conversationUser = conversationUser;
|
||||
this.editText = editText;
|
||||
this.viewThemeUtils = viewThemeUtils;
|
||||
}
|
||||
|
||||
@OptIn(markerClass = kotlin.ExperimentalStdlibApi.class)
|
||||
@Override
|
||||
public boolean onPopupItemClicked(Editable editable, Mention item) {
|
||||
CharPolicy.TextSpan range = CharPolicy.getQueryRange(editable);
|
||||
if (range == null) {
|
||||
return false;
|
||||
}
|
||||
String replacement = item.getLabel();
|
||||
|
||||
StringBuilder replacementStringBuilder = new StringBuilder(Objects.requireNonNull(item.getLabel()));
|
||||
for (EmojiRange emojiRange : Emojis.emojis(replacement)) {
|
||||
replacementStringBuilder.delete(emojiRange.range.getStart(), emojiRange.range.getEndInclusive());
|
||||
}
|
||||
|
||||
String charSequence = " ";
|
||||
editable.replace(range.getStart(), range.getEnd(), charSequence + replacementStringBuilder + " ");
|
||||
String id;
|
||||
if (item.getMentionId() != null) id = item.getMentionId(); else id = item.getId();
|
||||
Spans.MentionChipSpan mentionChipSpan =
|
||||
new Spans.MentionChipSpan(DisplayUtils.getDrawableForMentionChipSpan(context,
|
||||
item.getId(),
|
||||
item.getRoomToken(),
|
||||
item.getLabel(),
|
||||
conversationUser,
|
||||
item.getSource(),
|
||||
R.xml.chip_you,
|
||||
editText,
|
||||
viewThemeUtils,
|
||||
"federated_users".equals(item.getSource())),
|
||||
BetterImageSpan.ALIGN_CENTER,
|
||||
id, item.getLabel());
|
||||
editable.setSpan(mentionChipSpan,
|
||||
range.getStart() + charSequence.length(),
|
||||
range.getStart() + replacementStringBuilder.length() + charSequence.length(),
|
||||
Spanned.SPAN_INCLUSIVE_INCLUSIVE);
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPopupVisibilityChanged(boolean shown) {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.callnotification
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import autodagger.AutoInjector
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.activities.CallActivity
|
||||
import com.nextcloud.talk.activities.CallBaseActivity
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.CallNotificationActivityBinding
|
||||
import com.nextcloud.talk.extensions.loadUserAvatar
|
||||
import com.nextcloud.talk.models.json.participants.Participant
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
|
||||
import com.nextcloud.talk.utils.NotificationUtils
|
||||
import com.nextcloud.talk.utils.SpreedFeatures
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ONE_TO_ONE
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
|
||||
import okhttp3.Cache
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
@SuppressLint("LongLogTag")
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class CallNotificationActivity : CallBaseActivity() {
|
||||
@JvmField
|
||||
@Inject
|
||||
var ncApi: NcApi? = null
|
||||
|
||||
@JvmField
|
||||
@Inject
|
||||
var cache: Cache? = null
|
||||
|
||||
@Inject
|
||||
lateinit var userManager: UserManager
|
||||
|
||||
private var roomToken: String? = null
|
||||
private var notificationTimestamp: Int? = null
|
||||
private var displayName: String? = null
|
||||
private var callFlag: Int = 0
|
||||
private var isOneToOneCall: Boolean = true
|
||||
private var conversationName: String? = null
|
||||
private var internalUserId: Long = -1
|
||||
|
||||
private var userBeingCalled: User? = null
|
||||
private var leavingScreen = false
|
||||
private var handler: Handler? = null
|
||||
private var binding: CallNotificationActivityBinding? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
binding = CallNotificationActivityBinding.inflate(layoutInflater)
|
||||
setContentView(binding!!.root)
|
||||
hideNavigationIfNoPipAvailable()
|
||||
|
||||
handleExtras()
|
||||
userBeingCalled = userManager.getUserWithId(internalUserId).blockingGet()
|
||||
|
||||
setupCallTypeDescription()
|
||||
binding!!.conversationNameTextView.text = displayName
|
||||
setupAvatar(isOneToOneCall, conversationName)
|
||||
initClickListeners()
|
||||
setupNotificationCanceledRoutine()
|
||||
}
|
||||
|
||||
private fun handleExtras() {
|
||||
val extras = intent.extras!!
|
||||
roomToken = extras.getString(KEY_ROOM_TOKEN, "")
|
||||
notificationTimestamp = extras.getInt(BundleKeys.KEY_NOTIFICATION_TIMESTAMP)
|
||||
displayName = extras.getString(BundleKeys.KEY_CONVERSATION_DISPLAY_NAME, "")
|
||||
callFlag = extras.getInt(BundleKeys.KEY_CALL_FLAG)
|
||||
isOneToOneCall = extras.getBoolean(KEY_ROOM_ONE_TO_ONE)
|
||||
conversationName = extras.getString(BundleKeys.KEY_CONVERSATION_NAME, "")
|
||||
internalUserId = extras.getLong(BundleKeys.KEY_INTERNAL_USER_ID)
|
||||
}
|
||||
|
||||
private fun setupAvatar(isOneToOneCall: Boolean, conversationName: String?) {
|
||||
if (isOneToOneCall) {
|
||||
binding!!.avatarImageView.loadUserAvatar(
|
||||
userBeingCalled!!,
|
||||
conversationName!!,
|
||||
true,
|
||||
false
|
||||
)
|
||||
} else {
|
||||
binding!!.avatarImageView.setImageResource(R.drawable.ic_circular_group)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupCallTypeDescription() {
|
||||
val apiVersion = ApiUtils.getConversationApiVersion(
|
||||
userBeingCalled!!,
|
||||
intArrayOf(
|
||||
ApiUtils.API_V4,
|
||||
ApiUtils.API_V3,
|
||||
1
|
||||
)
|
||||
)
|
||||
|
||||
if (apiVersion >= ApiUtils.API_V3) {
|
||||
val hasCallFlags = hasSpreedFeatureCapability(
|
||||
userBeingCalled?.capabilities?.spreedCapability!!,
|
||||
SpreedFeatures.CONVERSATION_CALL_FLAGS
|
||||
)
|
||||
if (hasCallFlags) {
|
||||
if (isInCallWithVideo(callFlag)) {
|
||||
binding!!.incomingCallVoiceOrVideoTextView.text = String.format(
|
||||
resources.getString(R.string.nc_call_video),
|
||||
resources.getString(R.string.nc_app_product_name)
|
||||
)
|
||||
} else {
|
||||
binding!!.incomingCallVoiceOrVideoTextView.text = String.format(
|
||||
resources.getString(R.string.nc_call_voice),
|
||||
resources.getString(R.string.nc_app_product_name)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val callDescriptionWithoutTypeInfo = String.format(
|
||||
resources.getString(R.string.nc_call_unknown),
|
||||
resources.getString(R.string.nc_app_product_name)
|
||||
)
|
||||
binding!!.incomingCallVoiceOrVideoTextView.text = callDescriptionWithoutTypeInfo
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupNotificationCanceledRoutine() {
|
||||
val notificationHandler = Handler(Looper.getMainLooper())
|
||||
notificationHandler.post(object : Runnable {
|
||||
override fun run() {
|
||||
if (NotificationUtils.isNotificationVisible(context, notificationTimestamp!!.toInt())) {
|
||||
notificationHandler.postDelayed(this, ONE_SECOND)
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (handler == null) {
|
||||
handler = Handler()
|
||||
try {
|
||||
cache!!.evictAll()
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Failed to evict cache")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initClickListeners() {
|
||||
binding!!.callAnswerVoiceOnlyView.setOnClickListener {
|
||||
Log.d(TAG, "accept call (voice only)")
|
||||
intent.putExtra(KEY_CALL_VOICE_ONLY, true)
|
||||
proceedToCall()
|
||||
}
|
||||
binding!!.callAnswerCameraView.setOnClickListener {
|
||||
Log.d(TAG, "accept call (with video)")
|
||||
intent.putExtra(KEY_CALL_VOICE_ONLY, false)
|
||||
proceedToCall()
|
||||
}
|
||||
binding!!.hangupButton.setOnClickListener { hangup() }
|
||||
}
|
||||
|
||||
private fun hangup() {
|
||||
leavingScreen = true
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun proceedToCall() {
|
||||
val callIntent = Intent(this, CallActivity::class.java)
|
||||
intent.putExtra(KEY_ROOM_ONE_TO_ONE, isOneToOneCall)
|
||||
callIntent.putExtras(intent.extras!!)
|
||||
startActivity(callIntent)
|
||||
}
|
||||
|
||||
private fun isInCallWithVideo(callFlag: Int): Boolean = (callFlag and Participant.InCallFlags.WITH_VIDEO) > 0
|
||||
|
||||
override fun onStop() {
|
||||
val notificationManager = NotificationManagerCompat.from(context)
|
||||
notificationManager.cancel(notificationTimestamp!!)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
public override fun onDestroy() {
|
||||
leavingScreen = true
|
||||
if (handler != null) {
|
||||
handler!!.removeCallbacksAndMessages(null)
|
||||
handler = null
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
isInPipMode = isInPictureInPictureMode
|
||||
if (isInPictureInPictureMode) {
|
||||
updateUiForPipMode()
|
||||
} else {
|
||||
updateUiForNormalMode()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateUiForPipMode() {
|
||||
binding!!.callAnswerButtons.visibility = View.INVISIBLE
|
||||
binding!!.incomingCallRelativeLayout.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
override fun updateUiForNormalMode() {
|
||||
binding!!.callAnswerButtons.visibility = View.VISIBLE
|
||||
binding!!.incomingCallRelativeLayout.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
override fun suppressFitsSystemWindows() {
|
||||
binding!!.callNotificationLayout.fitsSystemWindows = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = CallNotificationActivity::class.simpleName
|
||||
const val ONE_SECOND: Long = 1000
|
||||
}
|
||||
}
|
||||
4602
app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
Normal file
4602
app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
Normal file
File diff suppressed because it is too large
Load diff
1126
app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt
Normal file
1126
app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue