/* * 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, 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(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(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(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(R.id.body).visibility = View.GONE (view.findViewById(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? = null } } override fun onCreateLoader(id: Int, args: Bundle?): Loader { return AccountLoader(this, account) } override fun refresh() { loaderManager.restartLoader(0, intent.extras, this) } override fun onLoadFinished(loader: Loader, info: AccountInfo) { accountInfo = info if (info.carddav != null) { val progress = findViewById(R.id.carddav_refreshing) as ProgressBar progress.visibility = if (info.carddav!!.refreshing) View.VISIBLE else View.GONE listCardDAV = findViewById(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(R.id.caldav_refreshing) as ProgressBar progress.visibility = if (info.caldav!!.refreshing) View.VISIBLE else View.GONE listCalDAV = findViewById(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(R.id.taskdav_refreshing) as ProgressBar progress.visibility = if (info.taskdav!!.refreshing) View.VISIBLE else View.GONE listTaskDAV = findViewById(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(R.id.taskdav_opentasks_warning) opentasksWarning.visibility = View.VISIBLE } } } override fun onLoaderReset(loader: Loader) { 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(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 { 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 { 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(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(R.id.title) as TextView tv.text = if (TextUtils.isEmpty(info.displayName)) info.uid else info.displayName tv = v.findViewById(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(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(R.id.read_only) readOnly.visibility = if (info.isReadOnly) View.VISIBLE else View.GONE val shared = v.findViewById(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" } }