You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
etesync-android/app/src/main/java/com/etesync/syncadapter/ui/AccountActivity.kt

611 lines
26 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package com.etesync.syncadapter.ui
import android.accounts.Account
import android.accounts.AccountManager
import android.app.LoaderManager
import android.content.*
import android.content.ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.text.TextUtils
import android.view.*
import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import at.bitfire.ical4android.TaskProvider.Companion.TASK_PROVIDERS
import at.bitfire.vcard4android.ContactsStorageException
import com.etebase.client.CollectionAccessLevel
import com.etebase.client.CollectionManager
import com.etebase.client.Utils
import com.etebase.client.exceptions.EtebaseException
import com.etesync.journalmanager.Crypto
import com.etesync.journalmanager.Exceptions
import com.etesync.journalmanager.JournalAuthenticator
import com.etesync.syncadapter.*
import com.etesync.syncadapter.Constants.*
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity
import com.etesync.syncadapter.model.MyEntityDataStore
import com.etesync.syncadapter.model.ServiceEntity
import com.etesync.syncadapter.resource.LocalAddressBook
import com.etesync.syncadapter.resource.LocalCalendar
import com.etesync.syncadapter.syncadapter.requestSync
import com.etesync.syncadapter.ui.etebase.CollectionActivity
import com.etesync.syncadapter.ui.etebase.InvitationsActivity
import com.etesync.syncadapter.ui.setup.SetupUserInfoFragment
import com.etesync.syncadapter.utils.HintManager
import com.etesync.syncadapter.utils.ShowcaseBuilder
import com.etesync.syncadapter.utils.packageInstalled
import com.google.android.material.snackbar.Snackbar
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.jetbrains.anko.doAsync
import tourguide.tourguide.ToolTip
import java.util.logging.Level
class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener, LoaderManager.LoaderCallbacks<AccountActivity.AccountInfo>, Refreshable {
private lateinit var account: Account
private lateinit var settings: AccountSettings
private var accountInfo: AccountInfo? = null
internal var listCalDAV: ListView? = null
internal var listCardDAV: ListView? = null
internal var listTaskDAV: ListView? = null
internal val openTasksPackage = "org.dmfs.tasks"
internal val tasksOrgPackage = "org.tasks"
private val onItemClickListener = AdapterView.OnItemClickListener { parent, view, position, _ ->
val list = parent as ListView
val adapter = list.adapter as ArrayAdapter<*>
val info = adapter.getItem(position) as CollectionListItemInfo
if (settings.isLegacy) {
startActivity(ViewCollectionActivity.newIntent(this@AccountActivity, account, info.legacyInfo!!))
} else {
startActivity(CollectionActivity.newIntent(this@AccountActivity, account, info.uid))
}
}
private val formattedFingerprint: String?
get() {
try {
if (settings.isLegacy) {
val settings = AccountSettings(this, account)
return Crypto.AsymmetricCryptoManager.getPrettyKeyFingerprint(settings.keyPair!!.publicKey)
} else {
val etebase = EtebaseLocalCache.getEtebase(this, HttpClient.sharedClient, settings)
val invitationManager = etebase.invitationManager
return Utils.prettyFingerprint(invitationManager.pubkey)
}
} catch (e: Exception) {
e.printStackTrace()
return e.localizedMessage
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
title = account.name
settings = AccountSettings(this, account)
setContentView(R.layout.activity_account)
val icMenu = ContextCompat.getDrawable(this, R.drawable.ic_menu_light)
// CardDAV toolbar
val tbCardDAV = findViewById<View>(R.id.carddav_menu) as Toolbar
tbCardDAV.overflowIcon = icMenu
tbCardDAV.inflateMenu(R.menu.carddav_actions)
tbCardDAV.setOnMenuItemClickListener(this)
tbCardDAV.setTitle(R.string.settings_carddav)
// CalDAV toolbar
val tbCalDAV = findViewById<View>(R.id.caldav_menu) as Toolbar
tbCalDAV.overflowIcon = icMenu
tbCalDAV.inflateMenu(R.menu.caldav_actions)
tbCalDAV.setOnMenuItemClickListener(this)
tbCalDAV.setTitle(R.string.settings_caldav)
// TaskDAV toolbar
val tbTaskDAV = findViewById<View>(R.id.taskdav_menu) as Toolbar
tbTaskDAV.overflowIcon = icMenu
tbTaskDAV.inflateMenu(R.menu.taskdav_actions)
tbTaskDAV.setOnMenuItemClickListener(this)
tbTaskDAV.setTitle(R.string.settings_taskdav)
if (!packageInstalled(this, tasksOrgPackage)) {
val tasksInstallMenuItem = tbTaskDAV.menu.findItem(R.id.install_tasksorg)
tasksInstallMenuItem.setVisible(true)
}
if (!packageInstalled(this, openTasksPackage)) {
val tasksInstallMenuItem = tbTaskDAV.menu.findItem(R.id.install_opentasks)
tasksInstallMenuItem.setVisible(true)
}
// load CardDAV/CalDAV journals
loaderManager.initLoader(0, intent.extras, this)
if (!HintManager.getHintSeen(this, HINT_VIEW_COLLECTION)) {
ShowcaseBuilder.getBuilder(this)
.setToolTip(ToolTip().setTitle(getString(R.string.tourguide_title)).setDescription(getString(R.string.account_showcase_view_collection)))
.playOn(tbCardDAV)
HintManager.setHintSeen(this, HINT_VIEW_COLLECTION, true)
}
if (settings.isLegacy) {
if (!SetupUserInfoFragment.hasUserInfo(this, account)) {
SetupUserInfoFragment.newInstance(account).show(supportFragmentManager, null)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.activity_account, menu)
if (settings.isLegacy) {
val invitations = menu.findItem(R.id.invitations)
invitations.setVisible(false)
}
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.sync_now -> requestSync()
R.id.settings -> {
val intent = Intent(this, AccountSettingsActivity::class.java)
intent.putExtra(Constants.KEY_ACCOUNT, account)
startActivity(intent)
}
R.id.delete_account -> AlertDialog.Builder(this@AccountActivity)
.setIcon(R.drawable.ic_error_dark)
.setTitle(R.string.account_delete_confirmation_title)
.setMessage(R.string.account_delete_confirmation_text)
.setNegativeButton(android.R.string.no, null)
.setPositiveButton(android.R.string.yes) { _, _ -> deleteAccount() }
.show()
R.id.show_fingerprint -> {
val view = layoutInflater.inflate(R.layout.fingerprint_alertdialog, null)
view.findViewById<View>(R.id.body).visibility = View.GONE
(view.findViewById<View>(R.id.fingerprint) as TextView).text = formattedFingerprint
val dialog = AlertDialog.Builder(this@AccountActivity)
.setIcon(R.drawable.ic_fingerprint_dark)
.setTitle(R.string.show_fingperprint_title)
.setView(view)
.setPositiveButton(android.R.string.yes) { _, _ -> }.create()
dialog.show()
}
R.id.invitations -> {
val intent = InvitationsActivity.newIntent(this, account)
startActivity(intent)
}
else -> return super.onOptionsItemSelected(item)
}
return true
}
fun installPackage(packagename: String) {
val fdroidPackageName = "org.fdroid.fdroid"
val gplayPackageName = "com.android.vending"
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(
"https://f-droid.org/en/packages/$packagename/")
}
if (packageInstalled(this, fdroidPackageName)) {
intent.setPackage(fdroidPackageName)
} else if (packageInstalled(this, gplayPackageName)) {
intent.apply {
data = Uri.parse(
"https://play.google.com/store/apps/details?id=$packagename")
setPackage(gplayPackageName)
}
}
startActivity(intent)
}
override fun onMenuItemClick(item: MenuItem): Boolean {
val info: CollectionInfo
when (item.itemId) {
R.id.create_calendar -> {
if (settings.isLegacy) {
info = CollectionInfo()
info.enumType = CollectionInfo.Type.CALENDAR
startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info))
} else {
startActivity(CollectionActivity.newCreateCollectionIntent(this@AccountActivity, account, ETEBASE_TYPE_CALENDAR))
}
}
R.id.create_tasklist -> {
if (settings.isLegacy) {
info = CollectionInfo()
info.enumType = CollectionInfo.Type.TASKS
startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info))
} else {
startActivity(CollectionActivity.newCreateCollectionIntent(this@AccountActivity, account, ETEBASE_TYPE_TASKS))
}
}
R.id.create_addressbook -> {
if (settings.isLegacy) {
info = CollectionInfo()
info.enumType = CollectionInfo.Type.ADDRESS_BOOK
startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info))
} else {
startActivity(CollectionActivity.newCreateCollectionIntent(this@AccountActivity, account, ETEBASE_TYPE_ADDRESS_BOOK))
}
}
R.id.install_tasksorg -> {
installPackage(tasksOrgPackage)
}
R.id.install_opentasks -> {
installPackage(openTasksPackage)
}
}
return false
}
/* LOADERS AND LOADED DATA */
class AccountInfo {
internal var carddav: ServiceInfo? = null
internal var caldav: ServiceInfo? = null
internal var taskdav: ServiceInfo? = null
class ServiceInfo {
internal var refreshing: Boolean = false
internal var infos: List<CollectionListItemInfo>? = null
}
}
override fun onCreateLoader(id: Int, args: Bundle?): Loader<AccountInfo> {
return AccountLoader(this, account)
}
override fun refresh() {
loaderManager.restartLoader(0, intent.extras, this)
}
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo) {
accountInfo = info
if (info.carddav != null) {
val progress = findViewById<View>(R.id.carddav_refreshing) as ProgressBar
progress.visibility = if (info.carddav!!.refreshing) View.VISIBLE else View.GONE
listCardDAV = findViewById<View>(R.id.address_books) as ListView
listCardDAV!!.isEnabled = !info.carddav!!.refreshing
listCardDAV!!.setAlpha(if (info.carddav!!.refreshing) 0.5f else 1f)
val adapter = CollectionListAdapter(this, account)
adapter.addAll(info.carddav!!.infos!!)
listCardDAV!!.adapter = adapter
listCardDAV!!.onItemClickListener = onItemClickListener
}
if (info.caldav != null) {
val progress = findViewById<View>(R.id.caldav_refreshing) as ProgressBar
progress.visibility = if (info.caldav!!.refreshing) View.VISIBLE else View.GONE
listCalDAV = findViewById<View>(R.id.calendars) as ListView
listCalDAV!!.isEnabled = !info.caldav!!.refreshing
listCalDAV!!.setAlpha(if (info.caldav!!.refreshing) 0.5f else 1f)
val adapter = CollectionListAdapter(this, account)
adapter.addAll(info.caldav!!.infos!!)
listCalDAV!!.adapter = adapter
listCalDAV!!.onItemClickListener = onItemClickListener
}
if (info.taskdav != null) {
val progress = findViewById<View>(R.id.taskdav_refreshing) as ProgressBar
progress.visibility = if (info.taskdav!!.refreshing) View.VISIBLE else View.GONE
listTaskDAV = findViewById<View>(R.id.tasklists) as ListView
listTaskDAV!!.isEnabled = !info.taskdav!!.refreshing
listTaskDAV!!.setAlpha(if (info.taskdav!!.refreshing) 0.5f else 1f)
val adapter = CollectionListAdapter(this, account)
adapter.addAll(info.taskdav!!.infos!!)
listTaskDAV!!.adapter = adapter
listTaskDAV!!.onItemClickListener = onItemClickListener
if (!packageInstalled(this, tasksOrgPackage) && !packageInstalled(this, openTasksPackage)) {
val opentasksWarning = findViewById<View>(R.id.taskdav_opentasks_warning)
opentasksWarning.visibility = View.VISIBLE
}
}
}
override fun onLoaderReset(loader: Loader<AccountInfo>) {
if (listCardDAV != null)
listCardDAV!!.adapter = null
if (listCalDAV != null)
listCalDAV!!.adapter = null
if (listTaskDAV != null)
listTaskDAV!!.adapter = null
}
private class AccountLoader(context: Context, private val account: Account) : AsyncTaskLoader<AccountInfo>(context), AccountUpdateService.RefreshingStatusListener, ServiceConnection, SyncStatusObserver {
private var davService: AccountUpdateService.InfoBinder? = null
private var syncStatusListener: Any? = null
override fun onStartLoading() {
syncStatusListener = ContentResolver.addStatusChangeListener(SYNC_OBSERVER_TYPE_ACTIVE, this)
context.bindService(Intent(context, AccountUpdateService::class.java), this, Context.BIND_AUTO_CREATE)
}
override fun onStopLoading() {
davService?.removeRefreshingStatusListener(this)
context.unbindService(this)
if (syncStatusListener != null)
ContentResolver.removeStatusChangeListener(syncStatusListener)
}
override fun onServiceConnected(name: ComponentName, service: IBinder) {
davService = service as AccountUpdateService.InfoBinder
davService!!.addRefreshingStatusListener(this, false)
forceLoad()
}
override fun onServiceDisconnected(name: ComponentName) {
davService = null
}
override fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean) {
forceLoad()
}
override fun onStatusChanged(which: Int) {
forceLoad()
}
private fun getLegacyJournals(data: MyEntityDataStore, serviceEntity: ServiceEntity): List<CollectionListItemInfo> {
return JournalEntity.getJournals(data, serviceEntity).map {
val info = it.info
val isAdmin = it.isOwner(account.name)
CollectionListItemInfo(it.uid, info.enumType!!, info.displayName!!, info.description ?: "", info.color, it.isReadOnly, isAdmin, info)
}
}
private fun getCollections(etebaseLocalCache: EtebaseLocalCache, colMgr: CollectionManager, type: CollectionInfo.Type): List<CollectionListItemInfo> {
val strType = when (type) {
CollectionInfo.Type.ADDRESS_BOOK -> ETEBASE_TYPE_ADDRESS_BOOK
CollectionInfo.Type.CALENDAR -> ETEBASE_TYPE_CALENDAR
CollectionInfo.Type.TASKS -> ETEBASE_TYPE_TASKS
}
synchronized(etebaseLocalCache) {
return etebaseLocalCache.collectionList(colMgr).map {
val meta = it.meta
if (strType != meta.collectionType) {
return@map null
}
val accessLevel = it.col.accessLevel
val isReadOnly = accessLevel == CollectionAccessLevel.ReadOnly
val isAdmin = accessLevel == CollectionAccessLevel.Admin
val metaColor = meta.color
val color = if (!metaColor.isNullOrBlank()) LocalCalendar.parseColor(metaColor) else null
CollectionListItemInfo(it.col.uid, type, meta.name, meta.description
?: "", color, isReadOnly, isAdmin, null)
}.filterNotNull()
}
}
override fun loadInBackground(): AccountInfo {
val info = AccountInfo()
val settings: AccountSettings
try {
settings = AccountSettings(context, account)
} catch (e: InvalidAccountException) {
return info
}
if (settings.isLegacy) {
val data = (context.applicationContext as App).data
for (serviceEntity in data.select(ServiceEntity::class.java).where(ServiceEntity.ACCOUNT.eq(account.name)).get()) {
val id = serviceEntity.id.toLong()
val service = serviceEntity.type!!
when (service) {
CollectionInfo.Type.ADDRESS_BOOK -> {
info.carddav = AccountInfo.ServiceInfo()
info.carddav!!.refreshing = davService != null && davService!!.isRefreshing(id) || ContentResolver.isSyncActive(account, App.addressBooksAuthority)
info.carddav!!.infos = getLegacyJournals(data, serviceEntity)
val accountManager = AccountManager.get(context)
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) {
val addressBook = LocalAddressBook(context, addrBookAccount, null)
try {
if (account == addressBook.mainAccount)
info.carddav!!.refreshing = info.carddav!!.refreshing or ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
} catch (e: ContactsStorageException) {
}
}
}
CollectionInfo.Type.CALENDAR -> {
info.caldav = AccountInfo.ServiceInfo()
info.caldav!!.refreshing = davService != null && davService!!.isRefreshing(id) ||
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY)
info.caldav!!.infos = getLegacyJournals(data, serviceEntity)
}
CollectionInfo.Type.TASKS -> {
info.taskdav = AccountInfo.ServiceInfo()
info.taskdav!!.refreshing = davService != null && davService!!.isRefreshing(id) ||
TASK_PROVIDERS.any {
ContentResolver.isSyncActive(account, it.authority)
}
info.taskdav!!.infos = getLegacyJournals(data, serviceEntity)
}
}
}
return info
}
val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name)
val httpClient = HttpClient.Builder(context).build().okHttpClient
val etebase = EtebaseLocalCache.getEtebase(context, httpClient, settings)
val colMgr = etebase.collectionManager
info.carddav = AccountInfo.ServiceInfo()
info.carddav!!.refreshing = ContentResolver.isSyncActive(account, App.addressBooksAuthority)
info.carddav!!.infos = getCollections(etebaseLocalCache, colMgr, CollectionInfo.Type.ADDRESS_BOOK)
val accountManager = AccountManager.get(context)
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) {
val addressBook = LocalAddressBook(context, addrBookAccount, null)
try {
if (account == addressBook.mainAccount)
info.carddav!!.refreshing = info.carddav!!.refreshing or ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
} catch (e: ContactsStorageException) {
}
}
info.caldav = AccountInfo.ServiceInfo()
info.caldav!!.refreshing = ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY)
info.caldav!!.infos = getCollections(etebaseLocalCache, colMgr, CollectionInfo.Type.CALENDAR)
info.taskdav = AccountInfo.ServiceInfo()
info.taskdav!!.refreshing = TASK_PROVIDERS.any {
ContentResolver.isSyncActive(account, it.authority)
}
info.taskdav!!.infos = getCollections(etebaseLocalCache, colMgr, CollectionInfo.Type.TASKS)
return info
}
}
/* LIST ADAPTERS */
data class CollectionListItemInfo(val uid: String, val enumType: CollectionInfo.Type, val displayName: String, val description: String, val color: Int?, val isReadOnly: Boolean, val isAdmin: Boolean, val legacyInfo: CollectionInfo?)
class CollectionListAdapter(context: Context, private val account: Account) : ArrayAdapter<CollectionListItemInfo>(context, R.layout.account_collection_item) {
override fun getView(position: Int, _v: View?, parent: ViewGroup): View {
var v = _v
if (v == null)
v = LayoutInflater.from(context).inflate(R.layout.account_collection_item, parent, false)
val info = getItem(position)!!
var tv = v!!.findViewById<View>(R.id.title) as TextView
tv.text = if (TextUtils.isEmpty(info.displayName)) info.uid else info.displayName
tv = v.findViewById<View>(R.id.description) as TextView
if (TextUtils.isEmpty(info.description))
tv.visibility = View.GONE
else {
tv.visibility = View.VISIBLE
tv.text = info.description
}
val vColor = v.findViewById<View>(R.id.color)
if (info.enumType == CollectionInfo.Type.ADDRESS_BOOK) {
vColor.visibility = View.GONE
} else {
vColor.setBackgroundColor(info.color ?: LocalCalendar.defaultColor)
}
val readOnly = v.findViewById<View>(R.id.read_only)
readOnly.visibility = if (info.isReadOnly) View.VISIBLE else View.GONE
val shared = v.findViewById<View>(R.id.shared)
val isOwner = info.isAdmin
shared.visibility = if (isOwner) View.GONE else View.VISIBLE
return v
}
}
/* USER ACTIONS */
private fun deleteAccount() {
val accountManager = AccountManager.get(this)
val settings = AccountSettings(this@AccountActivity, account)
doAsync {
if (settings.isLegacy) {
val authToken = settings.authToken
val principal = settings.uri?.toHttpUrlOrNull()
try {
val httpClient = HttpClient.Builder(this@AccountActivity, null, authToken).build().okHttpClient
val journalAuthenticator = JournalAuthenticator(httpClient, principal!!)
journalAuthenticator.invalidateAuthToken(authToken)
} catch (e: Exceptions.HttpException) {
// Ignore failures for now
Logger.log.warning(e.toString())
}
} else {
val etebaseLocalCache = EtebaseLocalCache.getInstance(this@AccountActivity, account.name)
etebaseLocalCache.clearUserCache()
try {
val httpClient = HttpClient.Builder(this@AccountActivity).build()
val etebase = EtebaseLocalCache.getEtebase(this@AccountActivity, httpClient.okHttpClient, settings)
etebase.logout()
} catch(e: EtebaseException) {
// Ignore failures for now
Logger.log.warning(e.toString())
}
}
}
if (Build.VERSION.SDK_INT >= 22)
accountManager.removeAccount(account, this, { future ->
try {
if (future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT))
finish()
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't remove account", e)
}
}, null)
else
accountManager.removeAccount(account, { future ->
try {
if (future.result)
finish()
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't remove account", e)
}
}, null)
}
private fun requestSync() {
requestSync(applicationContext, account)
Snackbar.make(findViewById(R.id.parent), R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show()
}
companion object {
val EXTRA_ACCOUNT = "account"
private val HINT_VIEW_COLLECTION = "ViewCollection"
}
}