Code owners
Assign users and groups as approvers for specific file changes. Learn more.
HomeActivity.kt 22.92 KiB
/*
* Copyright (C) 2004-2024 Savoir-faire Linux Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package cx.ring.client
import android.app.SearchManager
import android.content.DialogInterface
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.view.WindowCompat
import androidx.core.view.doOnNextLayout
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import cx.ring.R
import cx.ring.about.AboutFragment
import cx.ring.account.AccountEditionFragment
import cx.ring.account.AccountWizardActivity
import cx.ring.account.JamiAccountSummaryFragment
import cx.ring.application.JamiApplication
import cx.ring.databinding.ActivityHomeBinding
import cx.ring.fragments.ContactPickerFragment
import cx.ring.fragments.ConversationFragment
import cx.ring.fragments.HomeFragment
import cx.ring.fragments.WelcomeJamiFragment
import cx.ring.service.DRingService
import cx.ring.settings.SettingsFragment
import cx.ring.utils.ContentUri
import cx.ring.utils.ContentUri.isJamiLink
import cx.ring.utils.ContentUri.toJamiLink
import cx.ring.utils.ConversationPath
import cx.ring.utils.DeviceUtils
import cx.ring.utils.getUiCustomizationFromConfigJson
import cx.ring.viewmodel.WelcomeJamiViewModel
import cx.ring.views.AvatarDrawable
import cx.ring.views.AvatarFactory.toAdaptiveIcon
import cx.ring.views.twopane.TwoPaneLayout
import dagger.hilt.android.AndroidEntryPoint
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import net.jami.model.Account
import net.jami.model.ConfigKey
import net.jami.model.Contact
import net.jami.model.Conversation
import net.jami.model.Uri
import net.jami.services.AccountService
import net.jami.services.ContactService
import net.jami.services.ConversationFacade
import net.jami.services.NotificationService
import net.jami.smartlist.ConversationItemViewModel
import net.jami.utils.takeFirstWhile
import org.json.JSONObject
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.math.max
@AndroidEntryPoint
class HomeActivity : AppCompatActivity(), ContactPickerFragment.OnContactedPicked {
private val welcomeJamiViewModel by lazy { ViewModelProvider(this)[WelcomeJamiViewModel::class.java] }
private var frameContent: Fragment? = null
private var fConversation: ConversationFragment? = null
private var fWelcomeJami: WelcomeJamiFragment? = null
private var mHomeFragment: HomeFragment? = null
@Inject lateinit
var mContactService: ContactService
@Inject lateinit
var mAccountService: AccountService
@Inject lateinit
var mConversationFacade: ConversationFacade
@Inject lateinit
var mNotificationService: NotificationService
private var mBinding: ActivityHomeBinding? = null
private var mMigrationDialog: AlertDialog? = null
private val mDisposable = CompositeDisposable()
private val conversationBackPressedCallback: OnBackPressedCallback =
object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (supportFragmentManager.backStackEntryCount == 0) {
removeFragment(fConversation)
fConversation = null
// Hiding the conversation
if (mBinding?.panel?.isSlideable == true) { // No space to keep the pane open
mBinding?.panel?.closePane()
} else showWelcomeFragment()
// Next back press doesn't have to be handled by this callback.
isEnabled = false
} else {
supportFragmentManager.popBackStack()
}
}
}
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
JamiApplication.instance?.startDaemon(this)
// Switch to TV if appropriate (could happen with buggy launcher)
if (DeviceUtils.isTv(this)) {
Log.d(TAG, "Switch to TV")
val intent = intent
intent.setClass(this, cx.ring.tv.main.HomeActivity::class.java)
finish()
startActivity(intent)
return
}
mBinding = ActivityHomeBinding.inflate(layoutInflater).also { binding ->
setContentView(binding.root)
//supportActionBar?.title = ""
binding.panel.addPanelListener(object : TwoPaneLayout.PanelListener {
override fun onPanelOpened(panel: View) {
conversationBackPressedCallback.isEnabled = true
}
override fun onPanelClosed(panel: View) {
conversationBackPressedCallback.isEnabled = false
removeFragment(fConversation)
removeFragment(fWelcomeJami)
fConversation = null
fWelcomeJami = null
}
})
}
WindowCompat.setDecorFitsSystemWindows(window, false)
mHomeFragment = supportFragmentManager.findFragmentById(R.id.home_fragment) as HomeFragment
frameContent = supportFragmentManager.findFragmentById(R.id.frame)
supportFragmentManager.addOnBackStackChangedListener {
frameContent = supportFragmentManager.findFragmentById(R.id.frame)
}
if (frameContent != null) {
mBinding!!.frame.isVisible = true
}
fConversation = supportFragmentManager
.findFragmentByTag(ConversationFragment::class.java.simpleName)
as? ConversationFragment?
if (fConversation != null) {
Log.w(TAG, "Restore conversation fragment $fConversation")
conversationBackPressedCallback.isEnabled = true
mBinding!!.panel.openPane()
} else {
Log.w(TAG, "No conversation Restored")
}
onBackPressedDispatcher.addCallback(this, conversationBackPressedCallback)
handleIntent(intent)
}
override fun onDestroy() {
Log.d(TAG, "onDestroy")
super.onDestroy()
mMigrationDialog?.apply {
if (isShowing) dismiss()
mMigrationDialog = null
}
frameContent = null
mHomeFragment = null
mDisposable.dispose()
mBinding = null
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
mBinding!!.panel.doOnNextLayout {
it as TwoPaneLayout
if (it.isSlideable) {
if (fConversation == null) {
it.closePane()
return@doOnNextLayout
}
it.openPane() // Force the pane to be open to show the conversation
} else {
if (fConversation == null) {
showWelcomeFragment()
it.openPane()
}
}
}
}
private fun handleIntent(intent: Intent) {
Log.d(TAG, "handleIntent: $intent")
when (intent.action) {
NotificationService.NOTIF_TRUST_REQUEST_MULTIPLE -> {
val accountId = intent.getStringExtra(NotificationService.NOTIF_TRUST_REQUEST_ACCOUNT_ID)
// Select the current account if it's not active
if (mAccountService.currentAccount?.accountId != accountId)
mAccountService.currentAccount = mAccountService.getAccount(accountId)
mHomeFragment!!.handleIntent(intent)
}
Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE -> {
val path = ConversationPath.fromBundle(intent.extras)
if (path != null) {
startConversation(path, intent)
} else {
intent.setClass(applicationContext, ShareActivity::class.java)
startActivity(intent)
}
}
Intent.ACTION_VIEW, DRingService.ACTION_CONV_ACCEPT -> {
val intentUri = intent.data
if (intentUri.isJamiLink()) {
mHomeFragment!!.handleIntent(Intent(Intent.ACTION_SEARCH).apply {
putExtra(SearchManager.QUERY, intentUri.toJamiLink())
})
} else {
val path = ConversationPath.fromUri(intent.data)
if (path != null) {
// Select the current account if it's not active
if (mAccountService.currentAccount?.accountId != path.accountId)
mAccountService.currentAccount = mAccountService.getAccount(path.accountId)
startConversation(path, intent)
}
}
}
Intent.ACTION_SEARCH -> {
mHomeFragment!!.handleIntent(intent)
}
}
}
private fun showMigrationDialog() {
if (mMigrationDialog != null) {
return
}
mMigrationDialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.account_migration_title_dialog)
.setMessage(R.string.account_migration_message_dialog)
.setIcon(R.drawable.baseline_warning_24)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
goToAccountSettings()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private val iconSize by lazy { max(ShortcutManagerCompat.getIconMaxHeight(this), ShortcutManagerCompat.getIconMaxWidth(this)) }
private val maxShortcuts by lazy { getMaxShareShortcuts() }
override fun onStart() {
Log.d(TAG, "onStart")
super.onStart()
mDisposable.add(
mAccountService.observableAccountList
.observeOn(DeviceUtils.uiScheduler)
.subscribe { accounts: List<Account> ->
if (accounts.isEmpty()) {
startActivity(Intent(this, AccountWizardActivity::class.java))
}
for (account in accounts) {
if (account.needsMigration()) {
showMigrationDialog()
break
}
}
})
mDisposable.add(mAccountService
.currentAccountSubject
.switchMap { obj -> obj.getConversationsSubject() }
.debounce(10, TimeUnit.SECONDS, Schedulers.computation())
.map { conversations -> conversations.takeFirstWhile(maxShortcuts)
{ it.mode.blockingFirst() != Conversation.Mode.Syncing }
}
.switchMapSingle { conversations ->
if (conversations.isEmpty())
Single.just(emptyArray<Any>())
else
Single.zip(conversations.mapTo(ArrayList(conversations.size))
{ c -> mContactService.getLoadedConversation(c)
.observeOn(Schedulers.computation())
.map { vm ->
Pair(vm, AvatarDrawable.Builder()
.withViewModel(vm)
.withCircleCrop(false)
.build(this)
.toAdaptiveIcon(iconSize))
}
}) { obs -> obs }
}
.subscribe(this::setShareShortcuts)
{ e -> Log.e(TAG, "Error generating conversation shortcuts", e) })
// Subject to check if a username is available
val usernameAvailabilitySubject = PublishSubject.create<String>()
mDisposable.add(
mAccountService.currentAccountSubject
.switchMap { account -> usernameAvailabilitySubject.map { Pair(account, it) } }
.debounce(500, TimeUnit.MILLISECONDS)
.switchMapSingle { (account, username) -> mAccountService.findRegistrationByName(account.accountId, "", username) }
.observeOn(DeviceUtils.uiScheduler)
.subscribe { welcomeJamiViewModel.checkIfUsernameIsAvailableResult(it) }
)
// Subscribe on account to display correct welcome fragment
mDisposable.add(
mAccountService.currentAccountSubject
.observeOn(DeviceUtils.uiScheduler)
.subscribe { account ->
// Can be null if the account doesn't have a config
val uiCustomization = try {
getUiCustomizationFromConfigJson(
configurationJson = JSONObject(account.config[ConfigKey.UI_CUSTOMIZATION]),
managerUri = account.config[ConfigKey.MANAGER_URI],
)
} catch (e: org.json.JSONException) {
null // If the JSON is invalid, we don't display the customization
}
welcomeJamiViewModel.init(
isJamiAccount = account.isJami,
jamiId = account.registeredName,
jamiHash = account.username ?: "",
onRegisterName = { mAccountService.registerName(account, it, "", "") },
onCheckUsernameAvailability = { usernameAvailabilitySubject.onNext(it) },
uiCustomization = uiCustomization,
)
val currentConversationAccountId =
ConversationPath.fromBundle(fConversation?.arguments)?.accountId
if (account.accountId != currentConversationAccountId) {
mBinding!!.panel.doOnNextLayout {
it as TwoPaneLayout
if (!it.isSlideable) showWelcomeFragment()
}
}
}
)
}
override fun onStop() {
Log.d(TAG, "onStop")
super.onStop()
mDisposable.clear()
}
/**
* Remove the fragment from the activity.
* @param fragment the fragment to remove, can be null
*/
private fun removeFragment(fragment: Fragment?) {
fragment?.let {
supportFragmentManager.beginTransaction()
.remove(it)
.commit()
}
}
fun showWelcomeFragment() {
val welcomeJamiFragment = WelcomeJamiFragment()
supportFragmentManager.beginTransaction()
.replace(
R.id.conversation,
welcomeJamiFragment,
welcomeJamiFragment::class.java.simpleName
)
.commit()
fWelcomeJami = welcomeJamiFragment
fConversation = null
}
fun startConversation(conversationId: String) {
mDisposable.add(mAccountService.currentAccountSubject
.firstElement()
.observeOn(DeviceUtils.uiScheduler)
.subscribe { account ->
startConversation(account.accountId, Uri.fromString(conversationId))
})
}
fun startConversation(accountId: String, conversationId: Uri) {
startConversation(ConversationPath(accountId, conversationId))
}
private fun startConversation(path: ConversationPath, intent: Intent? = null) {
val conversation = ConversationFragment().apply {
arguments = path.toBundle()
}
Log.w(TAG, "startConversation $path old:$fConversation ${supportFragmentManager.backStackEntryCount}")
// If a conversation is already displayed, we replace it,
// else we add it
conversationBackPressedCallback.isEnabled = true
supportFragmentManager.beginTransaction()
.replace(R.id.conversation, conversation, ConversationFragment.TAG)
.runOnCommit {
intent?.let { conversation.handleShareIntent(it) }
}.commit()
fConversation = conversation
mBinding!!.panel.openPane()
}
fun goToAdvancedSettings() {
if (frameContent is SettingsFragment) {
return
}
val fragment = SettingsFragment()
frameContent = fragment
supportFragmentManager
.beginTransaction()
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
.replace(R.id.frame, fragment, SettingsFragment.TAG)
.addToBackStack(SettingsFragment.TAG)
.commit()
mBinding!!.frame.isVisible = true
}
fun goToAbout() {
if (frameContent is AboutFragment) {
return
}
val fragment = AboutFragment()
frameContent = fragment
mBinding!!.frame.isVisible = true
supportFragmentManager
.beginTransaction()
//.setCustomAnimations(android.R.animator.fade_in, android.R.animator.fade_out, android.R.animator.fade_in, android.R.animator.fade_out)
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN)
.replace(R.id.frame, fragment, AboutFragment.TAG)
.addToBackStack(AboutFragment.TAG)
.commit()
}
/** Go to "account settings" parameters. Should be called only if an account is loaded. */
fun goToAccountSettings() {
val account = mAccountService.currentAccount
if (account == null) {
Log.e(TAG, "No account loaded, cannot open \"Account settings\"")
return
}
if (account.needsMigration()) {
Log.d(TAG, "launchAccountMigrationActivity: Launch account migration activity")
val intent = Intent(this, AccountWizardActivity::class.java)
.setData(
android.net.Uri.withAppendedPath(
ContentUri.ACCOUNTS_CONTENT_URI, account.accountId
)
)
startActivityForResult(intent, 1)
} else {
if (account.isJami) { // display JamiAccountSummary
if (frameContent is JamiAccountSummaryFragment) return
val fragment = JamiAccountSummaryFragment().apply {
arguments = Bundle().apply { putString(AccountEditionFragment.ACCOUNT_ID_KEY, account.accountId) }
}
// Place it into the frame
frameContent = fragment
supportFragmentManager.beginTransaction()
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
.replace(R.id.frame, fragment, JamiAccountSummaryFragment.TAG)
.addToBackStack(JamiAccountSummaryFragment.TAG)
.commit()
} else { //is SIP account --> display SIPView
// If already on account settings, do nothing
if (frameContent is AccountEditionFragment) return
// Create the fragment
val fragment = AccountEditionFragment()
fragment.arguments = Bundle().apply {
putString(AccountEditionFragment.ACCOUNT_ID_KEY, account.accountId)
}
// Place it into the frame
frameContent = fragment
supportFragmentManager.beginTransaction()
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
.replace(R.id.frame, fragment, AccountEditionFragment.TAG)
.addToBackStack(AccountEditionFragment.TAG)
.commit()
}
mBinding!!.frame.isVisible = true
}
}
private fun getMaxShareShortcuts() =
ShortcutManagerCompat.getMaxShortcutCountPerActivity(this).takeIf { it > 0 } ?: 4
private fun setShareShortcuts(conversations: Array<Any>) {
val shortcutInfoList = conversations.map { c ->
val conversation = c as Pair<ConversationItemViewModel, IconCompat>
val title = conversation.first.title
val path = ConversationPath(conversation.first.accountId, conversation.first.uri)
val key = path.toKey()
ShortcutInfoCompat.Builder(this, key)
.setIsConversation()
.setShortLabel(title)
.setPerson(Person.Builder()
.setName(title)
.setKey(key)
.build())
.setLongLived(true)
.setIcon(conversation.second)
.setCategories(setOf(CONVERSATIONS_CATEGORY))
.setIntent(Intent(Intent.ACTION_SEND, android.net.Uri.EMPTY, this, HomeActivity::class.java)
.putExtras(path.toBundle()))
.build()
}
try {
Log.d(TAG, "Adding shortcuts: " + shortcutInfoList.size)
ShortcutManagerCompat.removeAllDynamicShortcuts(this)
ShortcutManagerCompat.addDynamicShortcuts(this, shortcutInfoList)
} catch (e: Exception) {
Log.w(TAG, "Error adding shortcuts", e)
}
}
override fun onContactPicked(accountId: String, contacts: Set<Contact>) {
mDisposable.add(mConversationFacade.createConversation(accountId, contacts)
.observeOn(DeviceUtils.uiScheduler)
.subscribe { conversation: Conversation ->
startConversation(conversation.accountId, conversation.uri)
})
}
companion object {
val TAG: String = HomeActivity::class.simpleName!!
const val REQUEST_CODE_CALL = 3
const val REQUEST_CODE_CONVERSATION = 4
const val REQUEST_CODE_PHOTO = 5
const val REQUEST_CODE_GALLERY = 6
const val REQUEST_PERMISSION_CAMERA = 113
const val REQUEST_PERMISSION_READ_STORAGE = 114
private const val CONVERSATIONS_CATEGORY = "conversations"
}
}