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