Create, rename, and delete microsoft lists

pull/2003/head
Alex Baker 2 years ago
parent 1793d95d4d
commit d589a89506

@ -673,6 +673,7 @@
<activity <activity
android:name=".preferences.ManageSpaceActivity" android:name=".preferences.ManageSpaceActivity"
android:theme="@style/Tasks" /> android:theme="@style/Tasks" />
<activity android:name="org.tasks.sync.microsoft.MicrosoftListSettingsActivity" />
<!-- launcher icons --> <!-- launcher icons -->

@ -461,7 +461,8 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
lifecycleScope.launch { lifecycleScope.launch {
val account = caldavDao.getAccountByUuid(calendar.account!!) val account = caldavDao.getAccountByUuid(calendar.account!!)
val caldavSettings = Intent(activity, account!!.listSettingsClass()) val caldavSettings = Intent(activity, account!!.listSettingsClass())
caldavSettings.putExtra(BaseCaldavCalendarSettingsActivity.EXTRA_CALDAV_CALENDAR, calendar) .putExtra(BaseCaldavCalendarSettingsActivity.EXTRA_CALDAV_ACCOUNT, account)
.putExtra(BaseCaldavCalendarSettingsActivity.EXTRA_CALDAV_CALENDAR, calendar)
startActivityForResult(caldavSettings, REQUEST_LIST_SETTINGS) startActivityForResult(caldavSettings, REQUEST_LIST_SETTINGS)
} }
true true

@ -132,6 +132,7 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() {
hideProgressIndicator() hideProgressIndicator()
when (t) { when (t) {
is HttpException -> showSnackbar(t.message) is HttpException -> showSnackbar(t.message)
is retrofit2.HttpException -> showSnackbar(t.message() ?: "HTTP ${t.code()}")
is DisplayableException -> showSnackbar(t.resId) is DisplayableException -> showSnackbar(t.resId)
is ConnectException -> showSnackbar(R.string.network_error) is ConnectException -> showSnackbar(R.string.network_error)
else -> showSnackbar(R.string.error_adding_account, t.message!!) else -> showSnackbar(R.string.error_adding_account, t.message!!)

@ -1,5 +1,6 @@
package org.tasks.data package org.tasks.data
import android.app.Activity
import android.content.Context import android.content.Context
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
@ -11,7 +12,6 @@ import androidx.room.PrimaryKey
import com.todoroo.andlib.data.Table import com.todoroo.andlib.data.Table
import com.todoroo.astrid.data.Task import com.todoroo.astrid.data.Task
import org.tasks.R import org.tasks.R
import org.tasks.activities.BaseListSettingsActivity
import org.tasks.caldav.BaseCaldavAccountSettingsActivity import org.tasks.caldav.BaseCaldavAccountSettingsActivity
import org.tasks.caldav.CaldavAccountSettingsActivity import org.tasks.caldav.CaldavAccountSettingsActivity
import org.tasks.caldav.CaldavCalendarSettingsActivity import org.tasks.caldav.CaldavCalendarSettingsActivity
@ -25,6 +25,7 @@ import org.tasks.etesync.EteSyncAccountSettingsActivity
import org.tasks.opentasks.OpenTaskAccountSettingsActivity import org.tasks.opentasks.OpenTaskAccountSettingsActivity
import org.tasks.opentasks.OpenTasksListSettingsActivity import org.tasks.opentasks.OpenTasksListSettingsActivity
import org.tasks.security.KeyStoreEncryption import org.tasks.security.KeyStoreEncryption
import org.tasks.sync.microsoft.MicrosoftListSettingsActivity
import java.net.HttpURLConnection import java.net.HttpURLConnection
@Entity(tableName = "caldav_accounts") @Entity(tableName = "caldav_accounts")
@ -102,10 +103,11 @@ class CaldavAccount : Parcelable {
val isMicrosoft: Boolean val isMicrosoft: Boolean
get() = accountType == TYPE_MICROSOFT get() = accountType == TYPE_MICROSOFT
fun listSettingsClass(): Class<out BaseListSettingsActivity> = when(accountType) { fun listSettingsClass(): Class<out Activity> = when(accountType) {
TYPE_LOCAL -> LocalListSettingsActivity::class.java TYPE_LOCAL -> LocalListSettingsActivity::class.java
TYPE_ETESYNC, TYPE_OPENTASKS -> OpenTasksListSettingsActivity::class.java TYPE_ETESYNC, TYPE_OPENTASKS -> OpenTasksListSettingsActivity::class.java
TYPE_ETEBASE -> EtebaseCalendarSettingsActivity::class.java TYPE_ETEBASE -> EtebaseCalendarSettingsActivity::class.java
TYPE_MICROSOFT -> MicrosoftListSettingsActivity::class.java
else -> CaldavCalendarSettingsActivity::class.java else -> CaldavCalendarSettingsActivity::class.java
} }

@ -7,12 +7,19 @@ import com.franmontiel.persistentcookiejar.persistence.CookiePersistor
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.internal.tls.OkHostnameVerifier import okhttp3.internal.tls.OkHostnameVerifier
import org.tasks.DebugNetworkInterceptor import org.tasks.DebugNetworkInterceptor
import org.tasks.caldav.TasksCookieJar import org.tasks.caldav.TasksCookieJar
import org.tasks.data.CaldavAccount
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.security.KeyStoreEncryption import org.tasks.security.KeyStoreEncryption
import org.tasks.sync.microsoft.MicrosoftService
import org.tasks.sync.microsoft.requestTokenRefresh
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Inject import javax.inject.Inject
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
@ -65,4 +72,39 @@ class HttpClientFactory @Inject constructor(
} }
return builder.build() return builder.build()
} }
suspend fun getMicrosoftService(account: CaldavAccount): MicrosoftService {
val authState = encryption.decrypt(account.password)?.let { AuthState.jsonDeserialize(it) }
?: throw RuntimeException("Missing credentials")
if (authState.needsTokenRefresh) {
val (token, ex) = context.requestTokenRefresh(authState)
authState.update(token, ex)
if (authState.isAuthorized) {
account.password = encryption.encrypt(authState.jsonSerializeString())
}
}
if (!authState.isAuthorized) {
throw RuntimeException("Needs authentication")
}
val client = newClient {
it.addInterceptor { chain ->
chain.proceed(
chain.request().newBuilder()
.header("Authorization", "Bearer ${authState.accessToken}")
.build()
)
}
}
val retrofit = Retrofit.Builder()
.baseUrl(URL_MICROSOFT)
.addConverterFactory(MoshiConverterFactory.create())
.client(client)
.build()
return retrofit.create(MicrosoftService::class.java)
}
companion object {
const val URL_MICROSOFT = "https://graph.microsoft.com"
val MEDIA_TYPE_JSON = "application/json".toMediaType()
}
} }

@ -19,6 +19,9 @@ suspend fun IdentityProvider.retrieveConfig(): AuthorizationServiceConfiguration
} }
} }
suspend fun Context.requestTokenRefresh(state: AuthState) =
requestToken(state.createTokenRefreshRequest())
suspend fun Context.requestTokenExchange(response: AuthorizationResponse) = suspend fun Context.requestTokenExchange(response: AuthorizationResponse) =
requestToken(response.createTokenExchangeRequest()) requestToken(response.createTokenExchangeRequest())

@ -0,0 +1,18 @@
package org.tasks.sync.microsoft
import com.google.gson.Gson
import retrofit2.Response
data class Error(
val error: ErrorBody,
) {
data class ErrorBody(
val code: String,
val message: String,
)
companion object {
fun <T> Response<T>.toMicrosoftError()
= errorBody()?.string()?.let { Gson().fromJson(it, Error::class.java) }
}
}

@ -0,0 +1,63 @@
package org.tasks.sync.microsoft
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import com.todoroo.astrid.activity.MainActivity
import com.todoroo.astrid.activity.TaskListFragment
import com.todoroo.astrid.api.CaldavFilter
import dagger.hilt.android.AndroidEntryPoint
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity
import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavCalendar
@AndroidEntryPoint
class MicrosoftListSettingsActivity : BaseCaldavCalendarSettingsActivity() {
private val viewModel: MicrosoftListSettingsActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launchWhenResumed {
viewModel
.viewState
.collect { state ->
state.error?.let { throwable ->
requestFailed(throwable)
viewModel.clearError()
}
if (state.deleted) {
setResult(Activity.RESULT_OK, Intent(TaskListFragment.ACTION_DELETED))
finish()
}
state.result?.let {
setResult(
Activity.RESULT_OK,
Intent(TaskListFragment.ACTION_RELOAD).putExtra(
MainActivity.OPEN_FILTER,
CaldavFilter(it)
)
)
finish()
}
}
}
}
override suspend fun createCalendar(caldavAccount: CaldavAccount, name: String, color: Int) =
viewModel.createList(name)
override suspend fun updateNameAndColor(
account: CaldavAccount,
calendar: CaldavCalendar,
name: String,
color: Int
) = viewModel.updateList(name)
override suspend fun deleteCalendar(
caldavAccount: CaldavAccount,
caldavCalendar: CaldavCalendar
) = viewModel.deleteList()
}

@ -0,0 +1,102 @@
package org.tasks.sync.microsoft
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.google.gson.Gson
import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import okhttp3.RequestBody.Companion.toRequestBody
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity
import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavCalendar
import org.tasks.data.CaldavDao
import org.tasks.http.HttpClientFactory
import org.tasks.http.HttpClientFactory.Companion.MEDIA_TYPE_JSON
import retrofit2.HttpException
import retrofit2.Response
import javax.inject.Inject
@HiltViewModel
class MicrosoftListSettingsActivityViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val httpClientFactory: HttpClientFactory,
private val caldavDao: CaldavDao,
private val taskDeleter: TaskDeleter,
) : ViewModel() {
data class ViewState(
val requestInFlight: Boolean = false,
val result: CaldavCalendar? = null,
val error: Throwable? = null,
val deleted: Boolean = false,
)
private val _viewState = MutableStateFlow(ViewState())
val viewState = _viewState.asStateFlow()
private val account: CaldavAccount =
savedStateHandle[BaseCaldavCalendarSettingsActivity.EXTRA_CALDAV_ACCOUNT]!!
val list: CaldavCalendar? =
savedStateHandle[BaseCaldavCalendarSettingsActivity.EXTRA_CALDAV_CALENDAR]
suspend fun createList(displayName: String) {
_viewState.update { it.copy(requestInFlight = true) }
val microsoftService = httpClientFactory.getMicrosoftService(account)
val taskList = TaskLists.TaskList(displayName = displayName)
val body = Gson().toJson(taskList).toRequestBody(MEDIA_TYPE_JSON)
val result = microsoftService.createList(body)
if (result.isSuccessful) {
val list = CaldavCalendar().apply {
account = this@MicrosoftListSettingsActivityViewModel.account.uuid
result.body()?.applyTo(this)
}
caldavDao.insert(list)
_viewState.update { it.copy(result = list) }
} else {
requestFailed(result)
}
}
suspend fun deleteList() {
_viewState.update { it.copy(requestInFlight = true) }
val microsoftService = httpClientFactory.getMicrosoftService(account)
val result = microsoftService.deleteList(list?.uuid!!)
if (result.isSuccessful) {
taskDeleter.delete(list)
_viewState.update { it.copy(deleted = true) }
} else {
requestFailed(result)
}
}
suspend fun updateList(displayName: String) {
_viewState.update { it.copy(requestInFlight = true) }
val microsoftService = httpClientFactory.getMicrosoftService(account)
val taskList = TaskLists.TaskList(displayName = displayName)
val body = Gson().toJson(taskList).toRequestBody(MEDIA_TYPE_JSON)
val result = microsoftService.updateList(list?.uuid!!, body)
if (result.isSuccessful) {
result.body()?.applyTo(list)
caldavDao.update(list)
_viewState.update { it.copy(result = list) }
} else {
requestFailed(result)
}
}
fun clearError() {
_viewState.update { it.copy(error = null) }
}
private fun <T> requestFailed(result: Response<T>) {
_viewState.update {
it.copy(
requestInFlight = false,
error = HttpException(result),
)
}
}
}

@ -22,7 +22,7 @@ interface MicrosoftService {
): Response<TaskLists.TaskList> ): Response<TaskLists.TaskList>
@DELETE("/v1.0/me/todo/lists/{listId}") @DELETE("/v1.0/me/todo/lists/{listId}")
suspend fun deleteList(@Path("listId") listId: String) suspend fun deleteList(@Path("listId") listId: String): Response<ResponseBody>
@GET("/v1.0/me/todo/lists/{listId}/tasks/delta") @GET("/v1.0/me/todo/lists/{listId}/tasks/delta")
suspend fun getTasks(@Path("listId") listId: String): Response<Tasks> suspend fun getTasks(@Path("listId") listId: String): Response<Tasks>

@ -1,6 +1,7 @@
package org.tasks.sync.microsoft package org.tasks.sync.microsoft
import com.squareup.moshi.Json import com.squareup.moshi.Json
import org.tasks.data.CaldavCalendar
data class TaskLists( data class TaskLists(
@field:Json(name = "@odata.context") val context: String, @field:Json(name = "@odata.context") val context: String,
@ -8,11 +9,24 @@ data class TaskLists(
@field:Json(name = "@odata.nextLink") val nextPage: String?, @field:Json(name = "@odata.nextLink") val nextPage: String?,
) { ) {
data class TaskList( data class TaskList(
@Json(name = "@odata.etag") val etag: String, @Json(name = "@odata.etag") val etag: String? = null,
val displayName: String, val displayName: String? = null,
val isOwner: Boolean, val isOwner: Boolean? = null,
val isShared: Boolean, val isShared: Boolean? = null,
val wellknownListName: String, val wellknownListName: String? = null,
val id: String, val id: String? = null,
) ) {
fun applyTo(list: CaldavCalendar) {
with (list) {
name = displayName
url = this@TaskList.id
uuid = this@TaskList.id
access = when {
isOwner == true -> CaldavCalendar.ACCESS_OWNER
isShared == true -> CaldavCalendar.ACCESS_READ_WRITE
else -> CaldavCalendar.ACCESS_UNKNOWN
}
}
}
}
} }
Loading…
Cancel
Save