1
0
mirror of https://github.com/etesync/android synced 2024-12-23 07:08:16 +00:00

Migration: add a tool for migrating to EteSync v2.

This commit is contained in:
Tom Hacohen 2020-10-19 16:09:06 +03:00
parent 2277888aee
commit 05b41c7f7d
9 changed files with 1069 additions and 5 deletions

View File

@ -245,6 +245,10 @@
android:name=".ui.etebase.InvitationsActivity"
android:exported="false"
/>
<activity
android:name=".ui.MigrateV2Activity"
android:exported="false"
/>
<activity
android:name=".ui.ViewCollectionActivity"
android:exported="false"

View File

@ -160,6 +160,7 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
if (settings.isLegacy) {
val invitations = menu.findItem(R.id.invitations)
invitations.setVisible(false)
menu.findItem(R.id.migration_v2).setVisible(true)
}
return true
}
@ -194,6 +195,10 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
val intent = InvitationsActivity.newIntent(this, account)
startActivity(intent)
}
R.id.migration_v2 -> {
val intent = MigrateV2Activity.newIntent(this, account)
startActivity(intent)
}
else -> return super.onOptionsItemSelected(item)
}
return true

View File

@ -0,0 +1,739 @@
package com.etesync.syncadapter.ui
import android.accounts.Account
import android.accounts.AccountManager
import android.app.Dialog
import android.app.ProgressDialog
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.provider.ContactsContract
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.*
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.Task
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.ContactsStorageException
import com.etebase.client.Account as EtebaseAccount
import com.etebase.client.Client
import com.etebase.client.Item
import com.etebase.client.ItemMetadata
import com.etesync.journalmanager.model.SyncEntry
import com.etesync.syncadapter.*
import com.etesync.syncadapter.model.*
import com.etesync.syncadapter.resource.LocalAddressBook
import com.etesync.syncadapter.resource.LocalCalendar
import com.etesync.syncadapter.ui.etebase.*
import com.etesync.syncadapter.ui.setup.CreateAccountFragment
import com.etesync.syncadapter.ui.setup.LoginCredentials
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import net.cachapa.expandablelayout.ExpandableLayout
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Request
import okhttp3.RequestBody
import org.jetbrains.anko.doAsync
import org.jetbrains.anko.uiThread
import java.io.StringReader
import java.net.URI
import java.util.*
import kotlin.collections.HashMap
class MigrateV2Activity : BaseActivity() {
private lateinit var accountV1: Account
private val model: AccountViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
accountV1 = intent.extras!!.getParcelable(EXTRA_ACCOUNT)!!
setContentView(R.layout.etebase_fragment_activity)
if (savedInstanceState == null) {
setTitle(R.string.migrate_v2_wizard_welcome_title)
supportFragmentManager.commit {
replace(R.id.fragment_container, WizardWelcomeFragment(accountV1))
}
}
}
companion object {
private val EXTRA_ACCOUNT = "account"
fun newIntent(context: Context, account: Account): Intent {
val intent = Intent(context, MigrateV2Activity::class.java)
intent.putExtra(EXTRA_ACCOUNT, account)
return intent
}
}
}
fun reportErrorHelper(context: Context, e: Throwable) {
AlertDialog.Builder(context)
.setIcon(R.drawable.ic_info_dark)
.setTitle(R.string.exception)
.setMessage(e.localizedMessage)
.setPositiveButton(android.R.string.yes) { _, _ -> }.show()
}
class WizardWelcomeFragment(private val accountV1: Account) : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val ret = inflater.inflate(R.layout.migrate_v2_wizard_welcome, container, false)
if (savedInstanceState == null) {
if (container != null) {
initUi(inflater, ret)
}
}
return ret
}
private fun initUi(inflater: LayoutInflater, v: View) {
v.findViewById<Button>(R.id.signup).setOnClickListener {
parentFragmentManager.commit {
replace(R.id.fragment_container, SignupFragment(accountV1))
addToBackStack(null)
}
}
v.findViewById<Button>(R.id.login).setOnClickListener {
parentFragmentManager.commit {
replace(R.id.fragment_container, LoginFragment(accountV1))
addToBackStack(null)
}
}
}
}
class SignupFragment(private val accountV1: Account) : Fragment() {
internal lateinit var editUserName: TextInputLayout
internal lateinit var editEmail: TextInputLayout
internal lateinit var editPassword: TextInputLayout
internal lateinit var showAdvanced: CheckedTextView
internal lateinit var customServer: TextInputEditText
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val v = inflater.inflate(R.layout.signup_fragment, container, false)
editUserName = v.findViewById(R.id.user_name)
editEmail = v.findViewById(R.id.email)
editPassword = v.findViewById(R.id.url_password)
showAdvanced = v.findViewById(R.id.show_advanced)
customServer = v.findViewById(R.id.custom_server)
// Hide stuff we don't need for the migration tool
v.findViewById<View>(R.id.trial_notice).visibility = View.GONE
editEmail.visibility = View.GONE
editEmail.editText?.setText(accountV1.name)
val login = v.findViewById<Button>(R.id.login)
login.visibility = View.GONE
val createAccount = v.findViewById<Button>(R.id.create_account)
createAccount.setOnClickListener {
val credentials = validateData()
if (credentials != null) {
SignupDoFragment(accountV1, credentials).show(requireFragmentManager(), null)
}
}
val advancedLayout = v.findViewById<View>(R.id.advanced_layout) as ExpandableLayout
showAdvanced.setOnClickListener {
if (showAdvanced.isChecked) {
showAdvanced.isChecked = false
advancedLayout.collapse()
} else {
showAdvanced.isChecked = true
advancedLayout.expand()
}
}
return v
}
protected fun validateData(): SignupCredentials? {
var valid = true
val userName = editUserName.editText?.text.toString()
// FIXME: this validation should only be done in the server, we are doing it here until the Java library supports field errors
if ((userName.length < 6) || (!userName.matches(Regex("""^[\w.-]+$""")))) {
editUserName.error = getString(R.string.login_username_error)
valid = false
} else {
editUserName.error = null
}
val email = editEmail.editText?.text.toString()
if (email.isEmpty()) {
editEmail.error = getString(R.string.login_email_address_error)
valid = false
} else {
editEmail.error = null
}
val password = editPassword.editText?.text.toString()
if (password.length < 8) {
editPassword.error = getString(R.string.signup_password_restrictions)
valid = false
} else {
editPassword.error = null
}
var uri: URI? = null
if (showAdvanced.isChecked) {
val server = customServer.text.toString()
// If this field is null, just use the default
if (!server.isEmpty()) {
val url = server.toHttpUrlOrNull()
if (url != null) {
uri = url.toUri()
customServer.error = null
} else {
customServer.error = getString(R.string.login_custom_server_error)
valid = false
}
}
}
return if (valid) SignupCredentials(uri, userName, email, password) else null
}
}
class SignupDoFragment(private val accountV1: Account, private val signupCredentials: SignupCredentials) : DialogFragment() {
private val model: ConfigurationViewModel by activityViewModels()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val progress = ProgressDialog(activity)
progress.setTitle(R.string.setting_up_encryption)
progress.setMessage(getString(R.string.setting_up_encryption_content))
progress.isIndeterminate = true
progress.setCanceledOnTouchOutside(false)
isCancelable = false
return progress
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
val settings = AccountSettings(requireContext(), accountV1)
// Mark the etesync v1 account as wanting migration
doAsync {
val httpClient = HttpClient.Builder(context, settings).setForeground(true).build().okHttpClient
val remote = settings.uri!!.toHttpUrlOrNull()!!.newBuilder()
.addPathSegments("etesync-v2/confirm-migration/")
.build()
val body = RequestBody.create(null, byteArrayOf())
val request = Request.Builder()
.post(body)
.url(remote)
.build()
val response = httpClient.newCall(request).execute()
uiThread {
if (context == null) {
dismissAllowingStateLoss()
return@uiThread
}
if (response.isSuccessful) {
model.signup(requireContext(), signupCredentials)
} else {
if (response.code == 400) {
reportErrorHelper(requireContext(), Error("User already migrated. Please login instead."))
} else {
reportErrorHelper(requireContext(), Error("Failed preparing account for migration"))
}
dismissAllowingStateLoss()
}
}
}
model.observe(this) {
if (it.isFailed) {
reportErrorHelper(requireContext(), it.error!!)
dismissAllowingStateLoss()
} else {
doAsync {
val httpClient = HttpClient.Builder(context).setForeground(true).build().okHttpClient
val client = Client.create(httpClient, it.url.toString())
val etebase = EtebaseAccount.restore(client, it.etebaseSession!!, null)
uiThread {
fragmentManager?.commit {
replace(R.id.fragment_container, WizardCollectionsFragment(accountV1, etebase))
addToBackStack(null)
}
dismissAllowingStateLoss()
}
}
} }
}
}
}
class LoginFragment(private val accountV1: Account) : Fragment() {
internal lateinit var editUserName: EditText
internal lateinit var editUrlPassword: TextInputLayout
internal lateinit var showAdvanced: CheckedTextView
internal lateinit var customServer: EditText
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val v = inflater.inflate(R.layout.login_credentials_fragment, container, false)
editUserName = v.findViewById<TextInputEditText>(R.id.user_name)
editUrlPassword = v.findViewById<TextInputLayout>(R.id.url_password)
showAdvanced = v.findViewById<CheckedTextView>(R.id.show_advanced)
customServer = v.findViewById<TextInputEditText>(R.id.custom_server)
v.findViewById<View>(R.id.create_account).visibility = View.GONE
val login = v.findViewById<View>(R.id.login) as Button
login.setOnClickListener {
val credentials = validateLoginData()
if (credentials != null)
LoginDoFragment(accountV1, credentials).show(fragmentManager!!, null)
}
val forgotPassword = v.findViewById<View>(R.id.forgot_password) as TextView
forgotPassword.setOnClickListener { WebViewActivity.openUrl(context!!, Constants.forgotPassword) }
val advancedLayout = v.findViewById<View>(R.id.advanced_layout) as ExpandableLayout
showAdvanced.setOnClickListener {
if (showAdvanced.isChecked) {
showAdvanced.isChecked = false
advancedLayout.collapse()
} else {
showAdvanced.isChecked = true
advancedLayout.expand()
}
}
return v
}
protected fun validateLoginData(): LoginCredentials? {
var valid = true
val userName = editUserName.text.toString()
if (userName.isEmpty()) {
editUserName.error = getString(R.string.login_email_address_error)
valid = false
} else {
editUserName.error = null
}
val password = editUrlPassword.editText?.text.toString()
if (password.isEmpty()) {
editUrlPassword.error = getString(R.string.login_password_required)
valid = false
} else {
editUrlPassword.error = null
}
var uri: URI? = null
if (showAdvanced.isChecked) {
val server = customServer.text.toString()
// If this field is null, just use the default
if (!server.isEmpty()) {
val url = server.toHttpUrlOrNull()
if (url != null) {
uri = url.toUri()
customServer.error = null
} else {
customServer.error = getString(R.string.login_custom_server_error)
valid = false
}
}
}
return if (valid) LoginCredentials(uri, userName, password) else null
}
}
class LoginDoFragment(private val accountV1: Account, private val loginCredentials: LoginCredentials) : DialogFragment() {
private val model: ConfigurationViewModel by activityViewModels()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val progress = ProgressDialog(activity)
progress.setTitle(R.string.setting_up_encryption)
progress.setMessage(getString(R.string.setting_up_encryption_content))
progress.isIndeterminate = true
progress.setCanceledOnTouchOutside(false)
isCancelable = false
return progress
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
val settings = AccountSettings(requireContext(), accountV1)
model.login(requireContext(), loginCredentials)
model.observe(this) {
if (it.isFailed) {
reportErrorHelper(requireContext(), it.error!!)
dismissAllowingStateLoss()
} else {
doAsync {
val httpClient = HttpClient.Builder(context).setForeground(true).build().okHttpClient
val client = Client.create(httpClient, it.url.toString())
val etebase = EtebaseAccount.restore(client, it.etebaseSession!!, null)
uiThread {
fragmentManager?.commit {
replace(R.id.fragment_container, WizardCollectionsFragment(accountV1, etebase))
addToBackStack(null)
}
dismissAllowingStateLoss()
}
}
} }
}
}
}
class WizardCollectionsFragment(private val accountV1: Account, private val etebase: EtebaseAccount) : Fragment() {
private val loadingModel: LoadingViewModel by viewModels()
private lateinit var info: AccountActivity.AccountInfo
private val migrateJournals = HashMap<String, AccountActivity.CollectionListItemInfo>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val ret = inflater.inflate(R.layout.migrate_v2_collections, container, false)
if (savedInstanceState == null) {
if (container != null) {
initUi(inflater, ret)
}
}
return ret
}
private fun initUi(inflater: LayoutInflater, v: View) {
v.findViewById<Button>(R.id.button_create).setOnClickListener {
MigrateCollectionsDoFragment(etebase, info, this.migrateJournals).show(requireFragmentManager(), null)
}
v.findViewById<Button>(R.id.button_skip).setOnClickListener {
activity?.finish()
}
loadAccount(v)
}
private fun loadAccount(v: View) {
val account = accountV1
info = AccountActivity.AccountInfo()
val data = (requireContext().applicationContext as App).data
loadingModel.setLoading(true)
doAsync {
try {
for (serviceEntity in data.select(ServiceEntity::class.java).where(ServiceEntity.ACCOUNT.eq(account.name)).get()) {
val service = serviceEntity.type!!
when (service) {
CollectionInfo.Type.ADDRESS_BOOK -> {
info.carddav = AccountActivity.AccountInfo.ServiceInfo()
info.carddav!!.infos = getLegacyJournals(data, serviceEntity)
val accountManager = AccountManager.get(context)
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) {
val addressBook = LocalAddressBook(requireContext(), 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 = AccountActivity.AccountInfo.ServiceInfo()
info.caldav!!.infos = getLegacyJournals(data, serviceEntity)
}
CollectionInfo.Type.TASKS -> {
info.taskdav = AccountActivity.AccountInfo.ServiceInfo()
info.taskdav!!.infos = getLegacyJournals(data, serviceEntity)
}
}
}
} finally {
uiThread {
loadingModel.setLoading(false)
}
}
uiThread {
if (info.carddav != null) {
val infos = info.carddav!!.infos!!
val listCardDAV = v.findViewById<View>(R.id.address_books) as ListView
val adapter = CollectionListAdapter(requireContext(), account)
adapter.addAll(infos)
listCardDAV.adapter = adapter
listCardDAV.setOnItemClickListener { adapterView, view, i, l ->
val infoItem = infos.get(i)
if (this@WizardCollectionsFragment.migrateJournals.contains(infoItem.uid)) {
this@WizardCollectionsFragment.migrateJournals.remove(infoItem.uid)
} else {
this@WizardCollectionsFragment.migrateJournals.set(infoItem.uid, infoItem)
}
view.findViewById<CheckBox>(R.id.sync).isChecked = this@WizardCollectionsFragment.migrateJournals.contains(infoItem.uid)
}
}
if (info.caldav != null) {
val infos = info.caldav!!.infos!!
val listCalDAV = v.findViewById<View>(R.id.calendars) as ListView
val adapter = CollectionListAdapter(requireContext(), account)
adapter.addAll(infos)
listCalDAV.adapter = adapter
listCalDAV.setOnItemClickListener { adapterView, view, i, l ->
val infoItem = infos.get(i)
if (this@WizardCollectionsFragment.migrateJournals.contains(infoItem.uid)) {
this@WizardCollectionsFragment.migrateJournals.remove(infoItem.uid)
} else {
this@WizardCollectionsFragment.migrateJournals.set(infoItem.uid, infoItem)
}
view.findViewById<CheckBox>(R.id.sync).isChecked = this@WizardCollectionsFragment.migrateJournals.contains(infoItem.uid)
}
}
if (info.taskdav != null) {
val infos = info.taskdav!!.infos!!
val listTaskDAV = v.findViewById<View>(R.id.tasklists) as ListView
val adapter = CollectionListAdapter(requireContext(), account)
adapter.addAll(infos)
listTaskDAV.adapter = adapter
listTaskDAV.setOnItemClickListener { adapterView, view, i, l ->
val infoItem = infos.get(i)
if (this@WizardCollectionsFragment.migrateJournals.contains(infoItem.uid)) {
this@WizardCollectionsFragment.migrateJournals.remove(infoItem.uid)
} else {
this@WizardCollectionsFragment.migrateJournals.set(infoItem.uid, infoItem)
}
view.findViewById<CheckBox>(R.id.sync).isChecked = this@WizardCollectionsFragment.migrateJournals.contains(infoItem.uid)
}
}
}
}
}
private fun getLegacyJournals(data: MyEntityDataStore, serviceEntity: ServiceEntity): List<AccountActivity.CollectionListItemInfo> {
return JournalEntity.getJournals(data, serviceEntity).map {
val info = it.info
val isAdmin = it.isOwner(accountV1.name)
AccountActivity.CollectionListItemInfo(it.uid, info.enumType!!, info.displayName!!, info.description
?: "", info.color, it.isReadOnly, isAdmin, info)
}
}
class CollectionListAdapter(context: Context, private val account: Account) : ArrayAdapter<AccountActivity.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)
v!!.findViewById<View>(R.id.sync).visibility = View.VISIBLE
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
}
}
}
class MigrateCollectionsDoFragment(private val etebase: EtebaseAccount, private val info: AccountActivity.AccountInfo,
private val migrateJournals: HashMap<String, AccountActivity.CollectionListItemInfo>) : DialogFragment() {
private val configurationModel: ConfigurationViewModel by activityViewModels()
private lateinit var progress: ProgressDialog
private val CHUNK_SIZE = 20
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
progress = ProgressDialog(activity)
progress.setTitle(R.string.migrate_v2_wizard_migrate_title)
progress.setMessage(getString(R.string.migrate_v2_wizard_migrate_title))
progress.isIndeterminate = true
progress.setCanceledOnTouchOutside(false)
isCancelable = false
return progress
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
migrate()
}
}
private fun migrate() {
val data = (requireContext().applicationContext as App).data
doAsync {
try {
val total = migrateJournals.size
var malformed = 0
var badMtime = 0
var i = 1
val colMgr = etebase.collectionManager
for (itemInfo in migrateJournals.values) {
uiThread {
progress.setMessage(getString(R.string.migrate_v2_wizard_migrate_progress, i, total))
}
val colType = when (itemInfo.enumType) {
CollectionInfo.Type.ADDRESS_BOOK -> Constants.ETEBASE_TYPE_ADDRESS_BOOK
CollectionInfo.Type.CALENDAR -> Constants.ETEBASE_TYPE_CALENDAR
CollectionInfo.Type.TASKS -> Constants.ETEBASE_TYPE_TASKS
}
val colMeta = ItemMetadata()
colMeta.name = itemInfo.displayName
colMeta.description = itemInfo.description
if (itemInfo.color != null) {
colMeta.color = String.format("#%06X", 0xFFFFFF and itemInfo.color)
}
colMeta.mtime = System.currentTimeMillis()
val collection = colMgr.create(colType, colMeta, "")
colMgr.upload(collection)
val migratedItems = HashMap<String, Item>()
val journalEntity = JournalModel.Journal.fetch(data, itemInfo.legacyInfo!!.getServiceEntity(data), itemInfo.uid)
val entries = data.select(EntryEntity::class.java).where(EntryEntity.JOURNAL.eq(journalEntity)).orderBy(EntryEntity.ID.asc()).get().toList()
val itemMgr = colMgr.getItemManager(collection)
var itemDone = 0
val toPush = LinkedList<Item>()
for (entry in entries) {
itemDone++
val inputReader = StringReader(entry.content.content)
val uid: String?
var lastModified: Long?
when (itemInfo.enumType) {
CollectionInfo.Type.ADDRESS_BOOK -> {
val contact = Contact.fromReader(inputReader, null)[0]
uid = contact.uid
lastModified = null
}
CollectionInfo.Type.CALENDAR -> {
val event = Event.eventsFromReader(inputReader)[0]
uid = event.uid
lastModified = event.lastModified?.dateTime?.time
}
CollectionInfo.Type.TASKS -> {
val task = Task.tasksFromReader(inputReader)[0]
uid = task.uid
lastModified = task.lastModified
}
}
if (uid == null) {
malformed++
continue
}
if (lastModified == null) {
// When we can't set mtime, set to the item's position in the change log so we at least maintain EteSync 1.0 ordering.
lastModified = System.currentTimeMillis() + itemDone
badMtime++
}
val item: Item
if (migratedItems.containsKey(uid)) {
val tmp = migratedItems.get(uid)!!
// We need to clone the item so we can push multiple versions at once
item = itemMgr.cacheLoad(itemMgr.cacheSaveWithContent(tmp))
item.setContent(entry.content.content)
val meta = item.meta
meta.mtime = lastModified
item.meta = meta
} else {
val meta = ItemMetadata()
meta.mtime = lastModified
meta.name = uid
item = itemMgr.create(meta, entry.content.content)
migratedItems.set(uid, item)
}
if (entry.content.isAction(SyncEntry.Actions.DELETE)) {
item.delete()
}
toPush.add(item)
if (toPush.size == CHUNK_SIZE) {
uiThread {
progress.setMessage(getString(R.string.migrate_v2_wizard_migrate_progress, i, total) + "\n" +
getString(R.string.migrate_v2_wizard_migrate_progress_entries, itemDone, entries.size))
}
itemMgr.batch(toPush.toTypedArray())
toPush.clear()
}
}
if (toPush.size > 0) {
itemMgr.batch(toPush.toTypedArray())
}
i++;
}
uiThread {
AlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_info_dark)
.setTitle(R.string.migrate_v2_wizard_migrate_title)
.setMessage(getString(R.string.migrate_v2_wizard_migrate_progress_done, badMtime, malformed))
.setPositiveButton(android.R.string.yes) { _, _ -> }
.setOnDismissListener {
requireFragmentManager().beginTransaction()
.replace(android.R.id.content, CreateAccountFragment.newInstance(configurationModel.account.value!!))
.addToBackStack(null)
.commitAllowingStateLoss()
}
.show()
}
} catch (e: Exception) {
uiThread { reportErrorHelper(requireContext(), e) }
} finally {
uiThread {
dismissAllowingStateLoss()
}
}
}
}
}

View File

@ -34,10 +34,7 @@ import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.HttpClient
import com.etesync.syncadapter.R
import com.etesync.syncadapter.ui.WebViewActivity
import com.etesync.syncadapter.ui.setup.BaseConfigurationFinder
import com.etesync.syncadapter.ui.setup.CreateAccountFragment
import com.etesync.syncadapter.ui.setup.DetectConfigurationFragment
import com.etesync.syncadapter.ui.setup.LoginCredentialsFragment
import com.etesync.syncadapter.ui.setup.*
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import net.cachapa.expandablelayout.ExpandableLayout
@ -190,7 +187,7 @@ class SignupDoFragment(private val signupCredentials: SignupCredentials) : Dialo
}
class ConfigurationViewModel : ViewModel() {
private val account = MutableLiveData<BaseConfigurationFinder.Configuration>()
val account = MutableLiveData<BaseConfigurationFinder.Configuration>()
private var asyncTask: Future<Unit>? = null
fun signup(context: Context, credentials: SignupCredentials) {
@ -221,6 +218,34 @@ class ConfigurationViewModel : ViewModel() {
}
}
// We just need it for the migration - maybe merge it with login later on
fun login(context: Context, credentials: LoginCredentials) {
asyncTask = doAsync {
val httpClient = HttpClient.Builder(context).build().okHttpClient
val uri = credentials.uri ?: URI(Constants.etebaseServiceUrl)
var etebaseSession: String? = null
var exception: Throwable? = null
try {
val client = Client.create(httpClient, uri.toString())
val etebase = Account.login(client, credentials.userName, credentials.password)
etebaseSession = etebase.save(null)
} catch (e: EtebaseException) {
exception = e
}
uiThread {
account.value = BaseConfigurationFinder.Configuration(
uri,
credentials.userName,
etebaseSession,
null,
null,
exception
)
}
}
}
fun cancelLoad() {
asyncTask?.cancel(true)
}

View File

@ -17,6 +17,16 @@
android:paddingLeft="12dp"
android:paddingRight="12dp">
<CheckBox
android:id="@+id/sync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:focusable="false"
android:focusableInTouchMode="false"
android:clickable="false"
android:layout_marginRight="4dp"
android:visibility="gone"/>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"

View File

@ -0,0 +1,194 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ScrollView
android:id="@+id/parent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/activity_margin">
<androidx.cardview.widget.CardView
android:id="@+id/carddav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:cardElevation="8dp"
app:cardUseCompatPadding="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/carddav_menu"
style="@style/toolbar_style"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="2dp"
android:theme="@style/toolbar_theme"
app:navigationIcon="@drawable/ic_people_light"
app:title="@string/settings_carddav"
tools:ignore="UnusedAttribute" />
<ProgressBar
android:id="@+id/carddav_refreshing"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone" />
<com.etesync.syncadapter.ui.widget.MaximizedListView
android:id="@+id/address_books"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:choiceMode="singleChoice" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:id="@+id/caldav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardElevation="8dp"
app:cardUseCompatPadding="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/caldav_menu"
style="@style/toolbar_style"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="2dp"
android:theme="@style/toolbar_theme"
app:navigationIcon="@drawable/ic_event_light"
app:title="@string/settings_caldav"
tools:ignore="UnusedAttribute" />
<ProgressBar
android:id="@+id/caldav_refreshing"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone" />
<com.etesync.syncadapter.ui.widget.MaximizedListView
android:id="@+id/calendars"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:choiceMode="multipleChoice"
android:descendantFocusability="beforeDescendants" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:id="@+id/taskdav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardElevation="8dp"
app:cardUseCompatPadding="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/taskdav_menu"
style="@style/toolbar_style"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="2dp"
android:theme="@style/toolbar_theme"
app:navigationIcon="@drawable/ic_task_light"
app:title="@string/settings_taskdav"
tools:ignore="UnusedAttribute" />
<ProgressBar
android:id="@+id/taskdav_refreshing"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/taskdav_opentasks_warning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="12dp"
android:paddingTop="8dp"
android:paddingRight="12dp"
android:paddingBottom="8dp"
android:text="@string/account_tasks_not_showing"
android:textAppearance="?android:attr/textAppearanceMedium"
android:visibility="gone" />
<com.etesync.syncadapter.ui.widget.MaximizedListView
android:id="@+id/tasklists"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:choiceMode="multipleChoice"
android:descendantFocusability="beforeDescendants" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</ScrollView>
<LinearLayout
android:id="@+id/buttons_holder"
style="@style/stepper_nav_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/button_skip"
style="@style/stepper_nav_button"
android:layout_width="0dp"
android:layout_weight="1"
android:text="@string/skip" />
<Space
style="@style/stepper_nav_button"
android:layout_width="0dp"
android:layout_weight="1" />
<Button
android:id="@+id/button_create"
style="@style/stepper_nav_button"
android:layout_width="0dp"
android:layout_weight="1"
android:text="@string/create" />
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<Space
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:gravity="center"
android:text="@string/migrate_v2_wizard_welcome_title"
android:textAppearance="@style/TextAppearance.AppCompat.Large" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:gravity="center"
android:text="@string/migrate_v2_wizard_welcome_body" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:gravity="center"
android:text="@string/migrate_v2_wizard_welcome_body_extra" />
<Space
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/stepper_nav_bar">
<Button
android:id="@+id/signup"
android:layout_width="0dp"
android:layout_weight="1"
android:text="@string/login_signup"
style="@style/stepper_nav_button"/>
<Space
android:layout_width="0dp"
android:layout_weight="1"
style="@style/stepper_nav_button"/>
<Button
android:id="@+id/login"
android:layout_width="0dp"
android:layout_weight="1"
android:text="@string/login_login"
style="@style/stepper_nav_button"/>
</LinearLayout>
</LinearLayout>

View File

@ -29,6 +29,11 @@
android:title="@string/invitations_title"
app:showAsAction="never"/>
<item android:id="@+id/migration_v2"
android:title="@string/migrate_v2_wizard_welcome_title"
android:visible="false"
app:showAsAction="never"/>
<item android:id="@+id/delete_account"
android:title="@string/account_delete"
app:showAsAction="never"/>

View File

@ -90,6 +90,15 @@
<string name="account_wizard_collections_title">Welcome to EteSync!</string>
<string name="account_wizard_collections_body">In order to start using EteSync you need to create collections to store your data. Click "Create" to create a default calendar, address book and a task list for you.</string>
<!-- Migrate v2 Wizard -->
<string name="migrate_v2_wizard_welcome_title">EteSync 2.0 Migration</string>
<string name="migrate_v2_wizard_welcome_body">This tool will help you migrate your data to EteSync 2.0. The migration doesn\'t delete any data. It only copies your data over to the new EteSync 2.0 server. This means that there is no risk of data-loss in the migration.</string>
<string name="migrate_v2_wizard_welcome_body_extra">Please make sure you have a good internet connection and enough battery as this may take a while.\nTo continue, please choose below whether you would like to signup for a new EteSync 2.0 account, or login using an existing one.</string>
<string name="migrate_v2_wizard_migrate_title">Migrating…</string>
<string name="migrate_v2_wizard_migrate_progress">Migrating collection %d/%d</string>
<string name="migrate_v2_wizard_migrate_progress_entries">Migrated entries %d/%d</string>
<string name="migrate_v2_wizard_migrate_progress_done">Done.\nModification time missing for %d entries (set to "now").\nIgnored %d malformed entries (probably safe to ignore).</string>
<!-- AccountUpdateService -->
<!-- AppSettingsActivity -->