repo created

This commit is contained in:
Fr4nz D13trich 2025-09-18 18:11:17 +02:00
commit 93184d21d1
1403 changed files with 189511 additions and 0 deletions

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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://"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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);
}

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View 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)

View 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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

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