diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d40e92c1..992ca263 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -127,7 +127,7 @@ + android:exported="true"> @@ -237,6 +237,10 @@ android:name=".ui.etebase.CollectionActivity" android:exported="false" /> + }.create() dialog.show() } + R.id.invitations -> { + val intent = InvitationsActivity.newIntent(this, account) + startActivity(intent) + } else -> return super.onOptionsItemSelected(item) } return true diff --git a/app/src/main/java/com/etesync/syncadapter/ui/etebase/InvitationsActivity.kt b/app/src/main/java/com/etesync/syncadapter/ui/etebase/InvitationsActivity.kt new file mode 100644 index 00000000..124a810b --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/ui/etebase/InvitationsActivity.kt @@ -0,0 +1,43 @@ +package com.etesync.syncadapter.ui.etebase + +import android.accounts.Account +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.fragment.app.commit +import com.etesync.syncadapter.R +import com.etesync.syncadapter.ui.BaseActivity + +class InvitationsActivity : BaseActivity() { + private lateinit var account: Account + private val model: AccountViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + account = intent.extras!!.getParcelable(EXTRA_ACCOUNT)!! + + setContentView(R.layout.etebase_collection_activity) + + if (savedInstanceState == null) { + model.loadAccount(this, account) + title = getString(R.string.invitations_title) + supportFragmentManager.commit { + replace(R.id.fragment_container, InvitationsListFragment()) + } + } + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + companion object { + private val EXTRA_ACCOUNT = "account" + + fun newIntent(context: Context, account: Account): Intent { + val intent = Intent(context, InvitationsActivity::class.java) + intent.putExtra(EXTRA_ACCOUNT, account) + return intent + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/etesync/syncadapter/ui/etebase/InvitationsListFragment.kt b/app/src/main/java/com/etesync/syncadapter/ui/etebase/InvitationsListFragment.kt new file mode 100644 index 00000000..77db9f12 --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/ui/etebase/InvitationsListFragment.kt @@ -0,0 +1,172 @@ +package com.etesync.syncadapter.ui.etebase + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.ListFragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.observe +import com.etebase.client.CollectionAccessLevel +import com.etebase.client.FetchOptions +import com.etebase.client.SignedInvitation +import com.etebase.client.Utils +import com.etesync.syncadapter.R +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.uiThread +import java.util.* +import java.util.concurrent.Future + + +class InvitationsListFragment : ListFragment(), AdapterView.OnItemClickListener { + private val model: AccountViewModel by activityViewModels() + private val invitationsModel: InvitationsViewModel by viewModels() + + private var emptyTextView: TextView? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.invitations_list, container, false) + + //This is instead of setEmptyText() function because of Google bug + //See: https://code.google.com/p/android/issues/detail?id=21742 + emptyTextView = view.findViewById(android.R.id.empty) + return view + } + + private fun setListAdapterInvitations(invitations: List) { + val context = context + if (context != null) { + val listAdapter = InvitationsListAdapter(context) + setListAdapter(listAdapter) + + listAdapter.addAll(invitations) + + emptyTextView!!.setText(R.string.invitations_list_empty) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + model.observe(this) { + invitationsModel.loadInvitations(it) + } + + invitationsModel.observe(this) { + setListAdapterInvitations(it) + } + + listView.onItemClickListener = this + } + + override fun onDestroyView() { + super.onDestroyView() + + invitationsModel.cancelLoad() + } + + override fun onItemClick(parent: AdapterView<*>, view_: View, position: Int, id: Long) { + val invitation = listAdapter?.getItem(position) as SignedInvitation + val fingerprint = Utils.prettyFingerprint(invitation.fromPubkey) + val view = layoutInflater.inflate(R.layout.invitation_alert_dialog, null) + view.findViewById(R.id.body).text = getString(R.string.invitations_accept_reject_dialog) + view.findViewById(R.id.fingerprint).text = fingerprint + + AlertDialog.Builder(requireContext()) + .setTitle(R.string.invitations_title) + .setIcon(R.drawable.ic_email_black) + .setView(view) + .setNegativeButton(R.string.invitations_reject) { dialogInterface, i -> + invitationsModel.reject(model.value!!, invitation) + } + .setPositiveButton(R.string.invitations_accept) { dialogInterface, i -> + invitationsModel.accept(model.value!!, invitation) + } + .show() + return + } + + internal inner class InvitationsListAdapter(context: Context) : ArrayAdapter(context, R.layout.invitations_list_item) { + + override fun getView(position: Int, _v: View?, parent: ViewGroup): View { + var v = _v + if (v == null) + v = LayoutInflater.from(context).inflate(R.layout.invitations_list_item, parent, false) + + val invitation = getItem(position)!! + + val tv = v!!.findViewById(R.id.title) as TextView + // FIXME: Should have a sensible string here + tv.text = "Invitation ${position}" + + // FIXME: Also mark admins + val readOnly = v.findViewById(R.id.read_only) + readOnly.visibility = if (invitation.accessLevel == CollectionAccessLevel.ReadOnly) View.VISIBLE else View.GONE + + return v + } + } +} + +class InvitationsViewModel : ViewModel() { + private val invitations = MutableLiveData>() + private var asyncTask: Future? = null + + fun loadInvitations(accountCollectionHolder: AccountHolder) { + asyncTask = doAsync { + val ret = LinkedList() + val invitationManager = accountCollectionHolder.etebase.invitationManager + var iterator: String? = null + var done = false + while (!done) { + val chunk = invitationManager.listIncoming(FetchOptions().iterator(iterator).limit(30)) + iterator = chunk.stoken + done = chunk.isDone + + ret.addAll(chunk.data) + } + + uiThread { + invitations.value = ret + } + } + } + + fun accept(accountCollectionHolder: AccountHolder, invitation: SignedInvitation) { + doAsync { + val invitationManager = accountCollectionHolder.etebase.invitationManager + invitationManager.accept(invitation) + val ret = invitations.value!!.filter { it != invitation } + + uiThread { + invitations.value = ret + } + } + } + + fun reject(accountCollectionHolder: AccountHolder, invitation: SignedInvitation) { + doAsync { + val invitationManager = accountCollectionHolder.etebase.invitationManager + invitationManager.reject(invitation) + val ret = invitations.value!!.filter { it != invitation } + + uiThread { + invitations.value = ret + } + } + } + + fun cancelLoad() { + asyncTask?.cancel(true) + } + + fun observe(owner: LifecycleOwner, observer: (List) -> Unit) = + invitations.observe(owner, observer) +} \ No newline at end of file diff --git a/app/src/main/res/layout/invitation_alert_dialog.xml b/app/src/main/res/layout/invitation_alert_dialog.xml new file mode 100644 index 00000000..fdc85129 --- /dev/null +++ b/app/src/main/res/layout/invitation_alert_dialog.xml @@ -0,0 +1,29 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/invitations_list.xml b/app/src/main/res/layout/invitations_list.xml new file mode 100644 index 00000000..fc98d988 --- /dev/null +++ b/app/src/main/res/layout/invitations_list.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/layout/invitations_list_item.xml b/app/src/main/res/layout/invitations_list_item.xml new file mode 100644 index 00000000..65432bef --- /dev/null +++ b/app/src/main/res/layout/invitations_list_item.xml @@ -0,0 +1,27 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/activity_account.xml b/app/src/main/res/menu/activity_account.xml index 96193221..a9bc96c4 100644 --- a/app/src/main/res/menu/activity_account.xml +++ b/app/src/main/res/menu/activity_account.xml @@ -25,6 +25,10 @@ android:title="@string/account_show_fingerprint" app:showAsAction="ifRoom"/> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fb7be7d8..d7962ff2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -168,6 +168,14 @@ Would you like to revoke %s\'s access?\nPlease be advised that a malicious user would potentially be able to retain access to encryption keys. Please refer to the FAQ for more information. Removing access to admins is currently not supported. + + Invitations + Loading invitations... + No invitations + Would you like to accept or reject the invitation? + Accept + Reject + About Main