From d589a895060a2f67b66763183c2b1b07b8e41ca4 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Wed, 14 Sep 2022 22:51:41 -0500 Subject: [PATCH] Create, rename, and delete microsoft lists --- app/src/main/AndroidManifest.xml | 1 + .../astrid/activity/TaskListFragment.kt | 3 +- .../BaseCaldavCalendarSettingsActivity.kt | 1 + .../main/java/org/tasks/data/CaldavAccount.kt | 6 +- .../java/org/tasks/http/HttpClientFactory.kt | 42 ++++++++ .../tasks/sync/microsoft/AppAuthExtensions.kt | 3 + .../java/org/tasks/sync/microsoft/Error.kt | 18 ++++ .../MicrosoftListSettingsActivity.kt | 63 +++++++++++ .../MicrosoftListSettingsActivityViewModel.kt | 102 ++++++++++++++++++ .../tasks/sync/microsoft/MicrosoftService.kt | 2 +- .../org/tasks/sync/microsoft/TaskLists.kt | 28 +++-- 11 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/org/tasks/sync/microsoft/Error.kt create mode 100644 app/src/main/java/org/tasks/sync/microsoft/MicrosoftListSettingsActivity.kt create mode 100644 app/src/main/java/org/tasks/sync/microsoft/MicrosoftListSettingsActivityViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 14e4aa28e..b8eb6f017 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -673,6 +673,7 @@ + diff --git a/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt b/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt index 9d3a0850b..ab2eec37f 100644 --- a/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt +++ b/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt @@ -461,7 +461,8 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL lifecycleScope.launch { val account = caldavDao.getAccountByUuid(calendar.account!!) 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) } true diff --git a/app/src/main/java/org/tasks/caldav/BaseCaldavCalendarSettingsActivity.kt b/app/src/main/java/org/tasks/caldav/BaseCaldavCalendarSettingsActivity.kt index ee6227a10..ac4778f44 100644 --- a/app/src/main/java/org/tasks/caldav/BaseCaldavCalendarSettingsActivity.kt +++ b/app/src/main/java/org/tasks/caldav/BaseCaldavCalendarSettingsActivity.kt @@ -132,6 +132,7 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() { hideProgressIndicator() when (t) { is HttpException -> showSnackbar(t.message) + is retrofit2.HttpException -> showSnackbar(t.message() ?: "HTTP ${t.code()}") is DisplayableException -> showSnackbar(t.resId) is ConnectException -> showSnackbar(R.string.network_error) else -> showSnackbar(R.string.error_adding_account, t.message!!) diff --git a/app/src/main/java/org/tasks/data/CaldavAccount.kt b/app/src/main/java/org/tasks/data/CaldavAccount.kt index 3cfbc0531..8e24083d7 100644 --- a/app/src/main/java/org/tasks/data/CaldavAccount.kt +++ b/app/src/main/java/org/tasks/data/CaldavAccount.kt @@ -1,5 +1,6 @@ package org.tasks.data +import android.app.Activity import android.content.Context import android.os.Parcel import android.os.Parcelable @@ -11,7 +12,6 @@ import androidx.room.PrimaryKey import com.todoroo.andlib.data.Table import com.todoroo.astrid.data.Task import org.tasks.R -import org.tasks.activities.BaseListSettingsActivity import org.tasks.caldav.BaseCaldavAccountSettingsActivity import org.tasks.caldav.CaldavAccountSettingsActivity import org.tasks.caldav.CaldavCalendarSettingsActivity @@ -25,6 +25,7 @@ import org.tasks.etesync.EteSyncAccountSettingsActivity import org.tasks.opentasks.OpenTaskAccountSettingsActivity import org.tasks.opentasks.OpenTasksListSettingsActivity import org.tasks.security.KeyStoreEncryption +import org.tasks.sync.microsoft.MicrosoftListSettingsActivity import java.net.HttpURLConnection @Entity(tableName = "caldav_accounts") @@ -102,10 +103,11 @@ class CaldavAccount : Parcelable { val isMicrosoft: Boolean get() = accountType == TYPE_MICROSOFT - fun listSettingsClass(): Class = when(accountType) { + fun listSettingsClass(): Class = when(accountType) { TYPE_LOCAL -> LocalListSettingsActivity::class.java TYPE_ETESYNC, TYPE_OPENTASKS -> OpenTasksListSettingsActivity::class.java TYPE_ETEBASE -> EtebaseCalendarSettingsActivity::class.java + TYPE_MICROSOFT -> MicrosoftListSettingsActivity::class.java else -> CaldavCalendarSettingsActivity::class.java } diff --git a/app/src/main/java/org/tasks/http/HttpClientFactory.kt b/app/src/main/java/org/tasks/http/HttpClientFactory.kt index a3814a789..af8b6e42a 100644 --- a/app/src/main/java/org/tasks/http/HttpClientFactory.kt +++ b/app/src/main/java/org/tasks/http/HttpClientFactory.kt @@ -7,12 +7,19 @@ import com.franmontiel.persistentcookiejar.persistence.CookiePersistor import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import net.openid.appauth.AuthState +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.internal.tls.OkHostnameVerifier import org.tasks.DebugNetworkInterceptor import org.tasks.caldav.TasksCookieJar +import org.tasks.data.CaldavAccount import org.tasks.preferences.Preferences 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.net.ssl.SSLContext @@ -65,4 +72,39 @@ class HttpClientFactory @Inject constructor( } 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() + } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/sync/microsoft/AppAuthExtensions.kt b/app/src/main/java/org/tasks/sync/microsoft/AppAuthExtensions.kt index ba274fe5a..c3d8a6ebc 100644 --- a/app/src/main/java/org/tasks/sync/microsoft/AppAuthExtensions.kt +++ b/app/src/main/java/org/tasks/sync/microsoft/AppAuthExtensions.kt @@ -19,6 +19,9 @@ suspend fun IdentityProvider.retrieveConfig(): AuthorizationServiceConfiguration } } +suspend fun Context.requestTokenRefresh(state: AuthState) = + requestToken(state.createTokenRefreshRequest()) + suspend fun Context.requestTokenExchange(response: AuthorizationResponse) = requestToken(response.createTokenExchangeRequest()) diff --git a/app/src/main/java/org/tasks/sync/microsoft/Error.kt b/app/src/main/java/org/tasks/sync/microsoft/Error.kt new file mode 100644 index 000000000..78c34cdba --- /dev/null +++ b/app/src/main/java/org/tasks/sync/microsoft/Error.kt @@ -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 Response.toMicrosoftError() + = errorBody()?.string()?.let { Gson().fromJson(it, Error::class.java) } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftListSettingsActivity.kt b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftListSettingsActivity.kt new file mode 100644 index 000000000..c40496040 --- /dev/null +++ b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftListSettingsActivity.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftListSettingsActivityViewModel.kt b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftListSettingsActivityViewModel.kt new file mode 100644 index 000000000..eb8703b1b --- /dev/null +++ b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftListSettingsActivityViewModel.kt @@ -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 requestFailed(result: Response) { + _viewState.update { + it.copy( + requestInFlight = false, + error = HttpException(result), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftService.kt b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftService.kt index 0a90ec278..91f5bfc9a 100644 --- a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftService.kt +++ b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftService.kt @@ -22,7 +22,7 @@ interface MicrosoftService { ): Response @DELETE("/v1.0/me/todo/lists/{listId}") - suspend fun deleteList(@Path("listId") listId: String) + suspend fun deleteList(@Path("listId") listId: String): Response @GET("/v1.0/me/todo/lists/{listId}/tasks/delta") suspend fun getTasks(@Path("listId") listId: String): Response diff --git a/app/src/main/java/org/tasks/sync/microsoft/TaskLists.kt b/app/src/main/java/org/tasks/sync/microsoft/TaskLists.kt index 3c21d6316..bb187cf53 100644 --- a/app/src/main/java/org/tasks/sync/microsoft/TaskLists.kt +++ b/app/src/main/java/org/tasks/sync/microsoft/TaskLists.kt @@ -1,6 +1,7 @@ package org.tasks.sync.microsoft import com.squareup.moshi.Json +import org.tasks.data.CaldavCalendar data class TaskLists( @field:Json(name = "@odata.context") val context: String, @@ -8,11 +9,24 @@ data class TaskLists( @field:Json(name = "@odata.nextLink") val nextPage: String?, ) { data class TaskList( - @Json(name = "@odata.etag") val etag: String, - val displayName: String, - val isOwner: Boolean, - val isShared: Boolean, - val wellknownListName: String, - val id: String, - ) + @Json(name = "@odata.etag") val etag: String? = null, + val displayName: String? = null, + val isOwner: Boolean? = null, + val isShared: Boolean? = null, + val wellknownListName: String? = null, + 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 + } + } + } + } } \ No newline at end of file