Merge pull request 'Develop' (#35) from develop into main
Reviewed-on: Gato/Planify-mobile#35
This commit is contained in:
@@ -0,0 +1,93 @@
|
|||||||
|
package com.planify.mobile.data.caldav
|
||||||
|
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
data class CalDavResponse(
|
||||||
|
val code: Int,
|
||||||
|
val body: String,
|
||||||
|
val headers: Map<String, String> = emptyMap(),
|
||||||
|
) {
|
||||||
|
val isSuccess get() = code in 200..299 || code == 207
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class CalDavClient @Inject constructor(private val okHttpClient: OkHttpClient) {
|
||||||
|
|
||||||
|
private val xmlMedia = "application/xml; charset=utf-8".toMediaType()
|
||||||
|
private val icalMedia = "text/calendar; charset=utf-8".toMediaType()
|
||||||
|
|
||||||
|
fun propfind(url: String, credentials: String, depth: String = "1", body: String): CalDavResponse {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.method("PROPFIND", body.toRequestBody(xmlMedia))
|
||||||
|
.header("Authorization", credentials)
|
||||||
|
.header("Depth", depth)
|
||||||
|
.header("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
.build()
|
||||||
|
return execute(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun report(url: String, credentials: String, depth: String = "1", body: String): CalDavResponse {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.method("REPORT", body.toRequestBody(xmlMedia))
|
||||||
|
.header("Authorization", credentials)
|
||||||
|
.header("Depth", depth)
|
||||||
|
.header("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
.build()
|
||||||
|
return execute(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun put(url: String, credentials: String, ical: String, etag: String? = null): CalDavResponse {
|
||||||
|
val builder = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.put(ical.toRequestBody(icalMedia))
|
||||||
|
.header("Authorization", credentials)
|
||||||
|
.header("Content-Type", "text/calendar; charset=utf-8")
|
||||||
|
if (etag != null) builder.header("If-Match", etag)
|
||||||
|
else builder.header("If-None-Match", "*")
|
||||||
|
return execute(builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(url: String, credentials: String, etag: String? = null): CalDavResponse {
|
||||||
|
val builder = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.delete()
|
||||||
|
.header("Authorization", credentials)
|
||||||
|
if (etag != null) builder.header("If-Match", etag)
|
||||||
|
return execute(builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun options(url: String, credentials: String): CalDavResponse {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.method("OPTIONS", null)
|
||||||
|
.header("Authorization", credentials)
|
||||||
|
.build()
|
||||||
|
return execute(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun execute(request: Request): CalDavResponse = try {
|
||||||
|
okHttpClient.newCall(request).execute().use { response ->
|
||||||
|
val headers = response.headers.toMap()
|
||||||
|
CalDavResponse(
|
||||||
|
code = response.code,
|
||||||
|
body = response.body?.string() ?: "",
|
||||||
|
headers = headers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
CalDavResponse(code = -1, body = e.message ?: "Network error")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun basicCredentials(username: String, password: String): String =
|
||||||
|
Credentials.basic(username, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.planify.mobile.data.caldav
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class CalDavCredentialStore @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val prefs by lazy {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"caldav_credentials",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savePassword(sourceId: String, password: String) {
|
||||||
|
prefs.edit().putString("pwd_$sourceId", password).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPassword(sourceId: String): String =
|
||||||
|
prefs.getString("pwd_$sourceId", "") ?: ""
|
||||||
|
|
||||||
|
fun deletePassword(sourceId: String) {
|
||||||
|
prefs.edit().remove("pwd_$sourceId").apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.planify.mobile.data.caldav
|
||||||
|
|
||||||
|
import com.planify.mobile.domain.model.CalDavType
|
||||||
|
import com.planify.mobile.domain.model.Source
|
||||||
|
import com.planify.mobile.domain.model.SourceCalDavData
|
||||||
|
import com.planify.mobile.domain.model.SourceType
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import org.xmlpull.v1.XmlPullParserFactory
|
||||||
|
import java.io.StringReader
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
sealed class DiscoveryResult {
|
||||||
|
data class Success(val sources: List<Source>) : DiscoveryResult()
|
||||||
|
data class Failure(val message: String) : DiscoveryResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
|
||||||
|
|
||||||
|
suspend fun discover(baseUrl: String, username: String, password: String): DiscoveryResult {
|
||||||
|
val credentials = CalDavClient.basicCredentials(username, password)
|
||||||
|
val normalizedBase = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
// Step 1: resolve principal URL
|
||||||
|
val principalUrl = resolvePrincipalUrl(normalizedBase, credentials, username)
|
||||||
|
?: return DiscoveryResult.Failure("Impossible de trouver le principal CalDAV")
|
||||||
|
|
||||||
|
// Step 2: find calendar home
|
||||||
|
val calendarHome = resolveCalendarHome(principalUrl, credentials)
|
||||||
|
?: return DiscoveryResult.Failure("Impossible de trouver le calendar home")
|
||||||
|
|
||||||
|
// Step 3: list VTODO-capable calendars
|
||||||
|
val calendars = listCalendars(calendarHome, credentials, username, baseUrl)
|
||||||
|
|
||||||
|
val caldavType = detectType(normalizedBase)
|
||||||
|
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||||
|
|
||||||
|
val sources = calendars.map { cal ->
|
||||||
|
Source(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
type = SourceType.CALDAV,
|
||||||
|
displayName = cal.displayName,
|
||||||
|
addedAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
caldavData = SourceCalDavData(
|
||||||
|
serverUrl = cal.url,
|
||||||
|
username = username,
|
||||||
|
calendarHomeUrl = calendarHome,
|
||||||
|
caldavType = caldavType,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return DiscoveryResult.Success(sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolvePrincipalUrl(baseUrl: String, credentials: String, username: String): String? {
|
||||||
|
val wellKnown = "$baseUrl/.well-known/caldav"
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<propfind xmlns="DAV:">
|
||||||
|
<prop><current-user-principal/></prop>
|
||||||
|
</propfind>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
for (url in listOf(wellKnown, baseUrl)) {
|
||||||
|
val resp = client.propfind(url, credentials, "0", body)
|
||||||
|
if (resp.isSuccess) {
|
||||||
|
val href = extractHref(resp.body, "current-user-principal")
|
||||||
|
if (href != null) return resolveUrl(baseUrl, href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: guess principal path
|
||||||
|
return "$baseUrl/principals/$username/"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveCalendarHome(principalUrl: String, credentials: String): String? {
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<prop><c:calendar-home-set/></prop>
|
||||||
|
</propfind>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val resp = client.propfind(principalUrl, credentials, "0", body)
|
||||||
|
if (!resp.isSuccess) return null
|
||||||
|
val href = extractHref(resp.body, "calendar-home-set") ?: return null
|
||||||
|
return resolveUrl(principalUrl, href)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listCalendars(homeUrl: String, credentials: String, username: String, baseUrl: String): List<CalendarInfo> {
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<prop>
|
||||||
|
<resourcetype/>
|
||||||
|
<displayname/>
|
||||||
|
<c:supported-calendar-component-set/>
|
||||||
|
</prop>
|
||||||
|
</propfind>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val resp = client.propfind(homeUrl, credentials, "1", body)
|
||||||
|
if (!resp.isSuccess) return emptyList()
|
||||||
|
return parseCalendarList(resp.body, baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CalendarInfo(val url: String, val displayName: String)
|
||||||
|
|
||||||
|
private fun parseCalendarList(xml: String, baseUrl: String): List<CalendarInfo> {
|
||||||
|
val results = mutableListOf<CalendarInfo>()
|
||||||
|
runCatching {
|
||||||
|
val factory = XmlPullParserFactory.newInstance()
|
||||||
|
val parser = factory.newPullParser()
|
||||||
|
parser.setInput(StringReader(xml))
|
||||||
|
|
||||||
|
var href = ""
|
||||||
|
var displayName = ""
|
||||||
|
var isCalendar = false
|
||||||
|
var supportsTodo = false
|
||||||
|
var inProp = false
|
||||||
|
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
val name = parser.name ?: ""
|
||||||
|
when {
|
||||||
|
event == XmlPullParser.START_TAG && name == "response" -> {
|
||||||
|
href = ""; displayName = ""; isCalendar = false; supportsTodo = false
|
||||||
|
}
|
||||||
|
event == XmlPullParser.START_TAG && name == "prop" -> inProp = true
|
||||||
|
event == XmlPullParser.END_TAG && name == "prop" -> inProp = false
|
||||||
|
event == XmlPullParser.START_TAG && name == "href" && !inProp -> {
|
||||||
|
parser.next()
|
||||||
|
href = parser.text ?: ""
|
||||||
|
}
|
||||||
|
event == XmlPullParser.START_TAG && name == "displayname" -> {
|
||||||
|
parser.next()
|
||||||
|
displayName = parser.text ?: ""
|
||||||
|
}
|
||||||
|
event == XmlPullParser.START_TAG && name == "calendar" -> isCalendar = true
|
||||||
|
event == XmlPullParser.START_TAG && name == "comp" -> {
|
||||||
|
if (parser.getAttributeValue(null, "name") == "VTODO") supportsTodo = true
|
||||||
|
}
|
||||||
|
event == XmlPullParser.END_TAG && name == "response" -> {
|
||||||
|
if (isCalendar && supportsTodo && href.isNotBlank()) {
|
||||||
|
val fullUrl = resolveUrl(baseUrl, href)
|
||||||
|
results.add(CalendarInfo(fullUrl, displayName.ifBlank { href.trimEnd('/').substringAfterLast('/') }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractHref(xml: String, parentTag: String): String? = runCatching {
|
||||||
|
val factory = XmlPullParserFactory.newInstance()
|
||||||
|
val parser = factory.newPullParser()
|
||||||
|
parser.setInput(StringReader(xml))
|
||||||
|
var inTarget = false
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
val name = parser.name ?: ""
|
||||||
|
when {
|
||||||
|
event == XmlPullParser.START_TAG && name == parentTag -> inTarget = true
|
||||||
|
event == XmlPullParser.END_TAG && name == parentTag -> inTarget = false
|
||||||
|
event == XmlPullParser.START_TAG && name == "href" && inTarget -> {
|
||||||
|
parser.next()
|
||||||
|
return parser.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun resolveUrl(base: String, href: String): String {
|
||||||
|
if (href.startsWith("http")) return href
|
||||||
|
val origin = base.substringBefore("://") + "://" + base.substringAfter("://").substringBefore("/")
|
||||||
|
return "$origin$href"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detectType(url: String): CalDavType =
|
||||||
|
if (url.contains("nextcloud") || url.contains("/remote.php/")) CalDavType.NEXTCLOUD
|
||||||
|
else CalDavType.GENERIC
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package com.planify.mobile.data.caldav
|
||||||
|
|
||||||
|
import com.planify.mobile.domain.model.BackendType
|
||||||
|
import com.planify.mobile.domain.model.Project
|
||||||
|
import com.planify.mobile.domain.model.Source
|
||||||
|
import com.planify.mobile.domain.model.SourceType
|
||||||
|
import com.planify.mobile.domain.model.Task
|
||||||
|
import com.planify.mobile.domain.repository.ProjectRepository
|
||||||
|
import com.planify.mobile.domain.repository.SourceRepository
|
||||||
|
import com.planify.mobile.domain.repository.TaskRepository
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import org.xmlpull.v1.XmlPullParserFactory
|
||||||
|
import java.io.StringReader
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
sealed class SyncResult {
|
||||||
|
data class Success(val added: Int = 0, val updated: Int = 0, val deleted: Int = 0) : SyncResult()
|
||||||
|
data class Failure(val message: String) : SyncResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class CalDavSyncManager @Inject constructor(
|
||||||
|
private val client: CalDavClient,
|
||||||
|
private val taskRepository: TaskRepository,
|
||||||
|
private val projectRepository: ProjectRepository,
|
||||||
|
private val sourceRepository: SourceRepository,
|
||||||
|
private val credentialStore: CalDavCredentialStore,
|
||||||
|
) {
|
||||||
|
|
||||||
|
// ─── #19 Initial sync ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun initialSync(source: Source): SyncResult {
|
||||||
|
val caldav = source.caldavData ?: return SyncResult.Failure("Source CalDAV sans données")
|
||||||
|
val credentials = CalDavClient.basicCredentials(caldav.username, getPassword(source.id))
|
||||||
|
val calendarUrl = caldav.serverUrl
|
||||||
|
|
||||||
|
// Ensure a local project exists for this source
|
||||||
|
val project = ensureProject(source)
|
||||||
|
|
||||||
|
val reportBody = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<d:prop>
|
||||||
|
<d:getetag/>
|
||||||
|
<c:calendar-data/>
|
||||||
|
</d:prop>
|
||||||
|
<c:filter>
|
||||||
|
<c:comp-filter name="VCALENDAR">
|
||||||
|
<c:comp-filter name="VTODO"/>
|
||||||
|
</c:comp-filter>
|
||||||
|
</c:filter>
|
||||||
|
</c:calendar-query>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val resp = client.report(calendarUrl, credentials, "1", reportBody)
|
||||||
|
if (!resp.isSuccess) return SyncResult.Failure("REPORT échoué : ${resp.code}")
|
||||||
|
|
||||||
|
val items = parseMultiStatus(resp.body, calendarUrl)
|
||||||
|
var added = 0; var updated = 0
|
||||||
|
|
||||||
|
for (item in items) {
|
||||||
|
val ical = item.calendarData ?: continue
|
||||||
|
val task = VTodoParser.parse(ical, project.id, item.href) ?: continue
|
||||||
|
val existing = taskRepository.getTaskById(task.id)
|
||||||
|
if (existing == null) {
|
||||||
|
taskRepository.insertTask(task.copy(etag = item.etag))
|
||||||
|
added++
|
||||||
|
} else if (existing.etag != item.etag) {
|
||||||
|
taskRepository.updateTask(task.copy(etag = item.etag))
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store sync-token for future incremental sync
|
||||||
|
val syncToken = extractSyncToken(resp.body)
|
||||||
|
updateSyncToken(source, syncToken)
|
||||||
|
|
||||||
|
return SyncResult.Success(added = added, updated = updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── #20 Incremental sync ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun incrementalSync(source: Source): SyncResult {
|
||||||
|
val caldav = source.caldavData ?: return SyncResult.Failure("Source CalDAV sans données")
|
||||||
|
val syncToken = source.lastSync
|
||||||
|
if (syncToken.isNullOrBlank()) return initialSync(source)
|
||||||
|
|
||||||
|
val credentials = CalDavClient.basicCredentials(caldav.username, getPassword(source.id))
|
||||||
|
val calendarUrl = caldav.serverUrl
|
||||||
|
val project = ensureProject(source)
|
||||||
|
|
||||||
|
val reportBody = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:sync-collection xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<d:sync-token>$syncToken</d:sync-token>
|
||||||
|
<d:sync-level>1</d:sync-level>
|
||||||
|
<d:prop>
|
||||||
|
<d:getetag/>
|
||||||
|
<c:calendar-data/>
|
||||||
|
</d:prop>
|
||||||
|
</d:sync-collection>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val resp = client.report(calendarUrl, credentials, "1", reportBody)
|
||||||
|
if (resp.code == 403 || resp.code == 409) {
|
||||||
|
// Sync token expired — fall back to full sync
|
||||||
|
return initialSync(source)
|
||||||
|
}
|
||||||
|
if (!resp.isSuccess) return SyncResult.Failure("sync-collection échoué : ${resp.code}")
|
||||||
|
|
||||||
|
val delta = parseMultiStatus(resp.body, calendarUrl)
|
||||||
|
var added = 0; var updated = 0; var deleted = 0
|
||||||
|
|
||||||
|
for (item in delta) {
|
||||||
|
if (item.isDeleted) {
|
||||||
|
// Find task with this icalUrl and delete it
|
||||||
|
taskRepository.getTasksByProject(project.id).collect { tasks ->
|
||||||
|
tasks.firstOrNull { it.icalUrl == item.href }?.let {
|
||||||
|
taskRepository.deleteTask(it.id)
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val ical = item.calendarData ?: continue
|
||||||
|
val task = VTodoParser.parse(ical, project.id, item.href) ?: continue
|
||||||
|
val existing = taskRepository.getTaskById(task.id)
|
||||||
|
if (existing == null) {
|
||||||
|
taskRepository.insertTask(task.copy(etag = item.etag))
|
||||||
|
added++
|
||||||
|
} else {
|
||||||
|
taskRepository.updateTask(task.copy(etag = item.etag))
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newToken = extractSyncToken(resp.body)
|
||||||
|
updateSyncToken(source, newToken)
|
||||||
|
|
||||||
|
return SyncResult.Success(added = added, updated = updated, deleted = deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── #21 CRUD push ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun pushTask(task: Task, source: Source): SyncResult {
|
||||||
|
val caldav = source.caldavData ?: return SyncResult.Failure("Source CalDAV sans données")
|
||||||
|
val credentials = CalDavClient.basicCredentials(caldav.username, getPassword(source.id))
|
||||||
|
val uid = task.calendarEventUid ?: task.id
|
||||||
|
val url = task.icalUrl ?: "${caldav.serverUrl.trimEnd('/')}/$uid.ics"
|
||||||
|
|
||||||
|
val ical = VTodoGenerator.generate(task)
|
||||||
|
val resp = client.put(url, credentials, ical, task.etag)
|
||||||
|
|
||||||
|
if (!resp.isSuccess) return SyncResult.Failure("PUT échoué : ${resp.code}")
|
||||||
|
|
||||||
|
// Update local etag and icalUrl
|
||||||
|
val newEtag = resp.headers["ETag"] ?: resp.headers["etag"]
|
||||||
|
taskRepository.updateTask(
|
||||||
|
task.copy(
|
||||||
|
icalUrl = url,
|
||||||
|
calendarEventUid = uid,
|
||||||
|
etag = newEtag,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SyncResult.Success(added = if (task.icalUrl == null) 1 else 0, updated = if (task.icalUrl != null) 1 else 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteRemoteTask(task: Task, source: Source): SyncResult {
|
||||||
|
val caldav = source.caldavData ?: return SyncResult.Failure("Source CalDAV sans données")
|
||||||
|
val url = task.icalUrl ?: return SyncResult.Failure("Tâche sans URL iCal")
|
||||||
|
val credentials = CalDavClient.basicCredentials(caldav.username, getPassword(source.id))
|
||||||
|
|
||||||
|
val resp = client.delete(url, credentials, task.etag)
|
||||||
|
if (!resp.isSuccess && resp.code != 404) return SyncResult.Failure("DELETE échoué : ${resp.code}")
|
||||||
|
|
||||||
|
taskRepository.deleteTask(task.id)
|
||||||
|
return SyncResult.Success(deleted = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private suspend fun ensureProject(source: Source): Project {
|
||||||
|
val existing = projectRepository.getAllProjects().let { flow ->
|
||||||
|
var found: Project? = null
|
||||||
|
flow.collect { list -> found = list.firstOrNull { it.sourceId == source.id }; return@collect }
|
||||||
|
found
|
||||||
|
}
|
||||||
|
if (existing != null) return existing
|
||||||
|
|
||||||
|
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||||
|
val project = Project(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
name = source.displayName,
|
||||||
|
sourceId = source.id,
|
||||||
|
backendType = BackendType.CALDAV,
|
||||||
|
)
|
||||||
|
projectRepository.insertProject(project)
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateSyncToken(source: Source, token: String?) {
|
||||||
|
if (token == null) return
|
||||||
|
sourceRepository.updateSource(source.copy(lastSync = token))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPassword(sourceId: String): String =
|
||||||
|
credentialStore.getPassword(sourceId)
|
||||||
|
|
||||||
|
private data class MultiStatusItem(
|
||||||
|
val href: String,
|
||||||
|
val etag: String? = null,
|
||||||
|
val calendarData: String? = null,
|
||||||
|
val isDeleted: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseMultiStatus(xml: String, baseUrl: String): List<MultiStatusItem> {
|
||||||
|
val results = mutableListOf<MultiStatusItem>()
|
||||||
|
runCatching {
|
||||||
|
val factory = XmlPullParserFactory.newInstance()
|
||||||
|
val parser = factory.newPullParser()
|
||||||
|
parser.setInput(StringReader(xml))
|
||||||
|
|
||||||
|
var href = ""; var etag: String? = null
|
||||||
|
var calData: String? = null; var status = ""; var inCalData = false
|
||||||
|
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
val name = parser.name ?: ""
|
||||||
|
when {
|
||||||
|
event == XmlPullParser.START_TAG && name == "response" -> {
|
||||||
|
href = ""; etag = null; calData = null; status = ""
|
||||||
|
}
|
||||||
|
event == XmlPullParser.START_TAG && name == "href" -> {
|
||||||
|
parser.next(); href = parser.text ?: ""
|
||||||
|
}
|
||||||
|
event == XmlPullParser.START_TAG && name == "getetag" -> {
|
||||||
|
parser.next(); etag = parser.text?.trim('"')
|
||||||
|
}
|
||||||
|
event == XmlPullParser.START_TAG && name == "calendar-data" -> {
|
||||||
|
inCalData = true; parser.next()
|
||||||
|
calData = if (parser.eventType == XmlPullParser.TEXT) parser.text else null
|
||||||
|
}
|
||||||
|
event == XmlPullParser.END_TAG && name == "calendar-data" -> inCalData = false
|
||||||
|
event == XmlPullParser.START_TAG && name == "status" -> {
|
||||||
|
parser.next(); status = parser.text ?: ""
|
||||||
|
}
|
||||||
|
event == XmlPullParser.END_TAG && name == "response" -> {
|
||||||
|
val isDeleted = status.contains("404")
|
||||||
|
val fullHref = if (href.startsWith("http")) href
|
||||||
|
else baseUrl.substringBefore("://") + "://" +
|
||||||
|
baseUrl.substringAfter("://").substringBefore("/") + href
|
||||||
|
results.add(MultiStatusItem(fullHref, etag, calData, isDeleted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractSyncToken(xml: String): String? {
|
||||||
|
val start = xml.indexOf("<d:sync-token>").takeIf { it >= 0 }
|
||||||
|
?: xml.indexOf("<sync-token>").takeIf { it >= 0 }
|
||||||
|
?: return null
|
||||||
|
val tagEnd = xml.indexOf('>', start) + 1
|
||||||
|
val closeStart = xml.indexOf('<', tagEnd)
|
||||||
|
if (closeStart < 0) return null
|
||||||
|
return xml.substring(tagEnd, closeStart).trim().takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.planify.mobile.data.caldav
|
||||||
|
|
||||||
|
import com.planify.mobile.domain.model.Task
|
||||||
|
import com.planify.mobile.domain.util.RRuleBuilder
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
object VTodoGenerator {
|
||||||
|
|
||||||
|
private val dtFormat = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
|
||||||
|
private val dateFormat = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||||
|
|
||||||
|
fun generate(task: Task): String {
|
||||||
|
val now = LocalDateTime.now().format(dtFormat)
|
||||||
|
val uid = task.calendarEventUid ?: task.id
|
||||||
|
|
||||||
|
return buildString {
|
||||||
|
appendLine("BEGIN:VCALENDAR")
|
||||||
|
appendLine("VERSION:2.0")
|
||||||
|
appendLine("PRODID:-//Planify Mobile//Android//EN")
|
||||||
|
appendLine("BEGIN:VTODO")
|
||||||
|
appendLine("UID:$uid")
|
||||||
|
appendLine("DTSTAMP:$now")
|
||||||
|
appendLine("CREATED:${formatDateTime(task.addedAt, now)}")
|
||||||
|
appendLine("LAST-MODIFIED:${formatDateTime(task.updatedAt, now)}")
|
||||||
|
appendLine("SUMMARY:${task.content.encodeIcal()}")
|
||||||
|
|
||||||
|
if (task.description.isNotBlank()) {
|
||||||
|
appendLine("DESCRIPTION:${task.description.encodeIcal()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLine("STATUS:${if (task.checked) "COMPLETED" else "NEEDS-ACTION"}")
|
||||||
|
|
||||||
|
if (task.checked && task.completedAt != null) {
|
||||||
|
appendLine("COMPLETED:${formatDateCompact(task.completedAt)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val icalPriority = appPriorityToIcal(task.priority)
|
||||||
|
if (icalPriority > 0) appendLine("PRIORITY:$icalPriority")
|
||||||
|
|
||||||
|
task.dueDate?.date?.let { due ->
|
||||||
|
appendLine("DUE;VALUE=DATE:${due.replace("-", "")}")
|
||||||
|
}
|
||||||
|
|
||||||
|
task.dueDate?.let { dd ->
|
||||||
|
RRuleBuilder.build(dd)?.let { appendLine(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.labels.isNotEmpty()) {
|
||||||
|
appendLine("CATEGORIES:${task.labels.joinToString(",") { it.encodeIcal() }}")
|
||||||
|
}
|
||||||
|
|
||||||
|
task.parentId?.let { appendLine("RELATED-TO;RELTYPE=PARENT:$it") }
|
||||||
|
|
||||||
|
appendLine("END:VTODO")
|
||||||
|
append("END:VCALENDAR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.encodeIcal(): String =
|
||||||
|
replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
|
||||||
|
|
||||||
|
private fun formatDateTime(iso: String?, fallback: String): String {
|
||||||
|
if (iso.isNullOrBlank()) return fallback
|
||||||
|
return runCatching {
|
||||||
|
val ldt = if (iso.contains("T")) LocalDateTime.parse(iso, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||||
|
else LocalDateTime.parse("${iso}T00:00:00", DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||||
|
ldt.format(dtFormat)
|
||||||
|
}.getOrDefault(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDateCompact(iso: String): String = runCatching {
|
||||||
|
"${iso.replace("-", "").take(8)}T000000Z"
|
||||||
|
}.getOrDefault(iso)
|
||||||
|
|
||||||
|
private fun appPriorityToIcal(p: Int): Int = when (p) {
|
||||||
|
1 -> 1
|
||||||
|
2 -> 5
|
||||||
|
3 -> 9
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.planify.mobile.data.caldav
|
||||||
|
|
||||||
|
import com.planify.mobile.domain.model.DueDate
|
||||||
|
import com.planify.mobile.domain.model.Task
|
||||||
|
import com.planify.mobile.domain.util.RRuleBuilder
|
||||||
|
|
||||||
|
object VTodoParser {
|
||||||
|
|
||||||
|
fun parse(ical: String, projectId: String, icalUrl: String? = null): Task? {
|
||||||
|
val lines = unfold(ical)
|
||||||
|
val vtodo = extractComponent(lines, "VTODO") ?: return null
|
||||||
|
val props = parseProperties(vtodo)
|
||||||
|
|
||||||
|
val uid = props["UID"] ?: return null
|
||||||
|
val summary = props["SUMMARY"]?.decodeIcal() ?: return null
|
||||||
|
val description = props["DESCRIPTION"]?.decodeIcal() ?: ""
|
||||||
|
|
||||||
|
val status = props["STATUS"]
|
||||||
|
val checked = status == "COMPLETED"
|
||||||
|
|
||||||
|
val priority = props["PRIORITY"]?.toIntOrNull()?.let { icalPriorityToApp(it) } ?: 4
|
||||||
|
|
||||||
|
val due = props["DUE"]?.let { parseDate(it) }
|
||||||
|
val dtstart = props["DTSTART"]?.let { parseDate(it) }
|
||||||
|
|
||||||
|
val rrule = props["RRULE"]
|
||||||
|
val dueDate: DueDate? = if (due != null) {
|
||||||
|
val recDueDate = rrule?.let { RRuleBuilder.parse(it) }
|
||||||
|
DueDate(
|
||||||
|
date = due,
|
||||||
|
isRecurring = recDueDate != null,
|
||||||
|
recurrencyType = recDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE,
|
||||||
|
recurrencyInterval = recDueDate?.recurrencyInterval ?: 1,
|
||||||
|
recurrencyWeekDays = recDueDate?.recurrencyWeekDays ?: emptyList(),
|
||||||
|
recurrencyCount = recDueDate?.recurrencyCount,
|
||||||
|
recurrencyEnd = recDueDate?.recurrencyEnd,
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
|
||||||
|
val categories = props["CATEGORIES"]?.split(",")?.map { it.trim() } ?: emptyList()
|
||||||
|
|
||||||
|
val parentRelated = props.entries
|
||||||
|
.firstOrNull { it.key.startsWith("RELATED-TO") && it.value.isNotBlank() }
|
||||||
|
?.value
|
||||||
|
|
||||||
|
val completedAt = props["COMPLETED"]?.let { parseDate(it) }
|
||||||
|
val createdAt = props["CREATED"]?.let { parseDateTime(it) } ?: ""
|
||||||
|
val lastModified = props["LAST-MODIFIED"]?.let { parseDateTime(it) } ?: ""
|
||||||
|
|
||||||
|
return Task(
|
||||||
|
id = uid,
|
||||||
|
content = summary,
|
||||||
|
description = description,
|
||||||
|
projectId = projectId,
|
||||||
|
parentId = parentRelated,
|
||||||
|
priority = priority,
|
||||||
|
checked = checked,
|
||||||
|
dueDate = dueDate,
|
||||||
|
labels = categories,
|
||||||
|
addedAt = createdAt,
|
||||||
|
updatedAt = lastModified,
|
||||||
|
completedAt = completedAt,
|
||||||
|
calendarEventUid = uid,
|
||||||
|
icalUrl = icalUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unfold(ical: String): List<String> {
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (line in ical.lines()) {
|
||||||
|
if (line.startsWith(" ") || line.startsWith("\t")) {
|
||||||
|
sb.append(line.substring(1))
|
||||||
|
} else {
|
||||||
|
if (sb.isNotEmpty()) result.add(sb.toString())
|
||||||
|
sb.clear()
|
||||||
|
sb.append(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sb.isNotEmpty()) result.add(sb.toString())
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractComponent(lines: List<String>, name: String): List<String>? {
|
||||||
|
val start = lines.indexOfFirst { it == "BEGIN:$name" }
|
||||||
|
val end = lines.indexOfFirst { it == "END:$name" }
|
||||||
|
if (start < 0 || end < 0) return null
|
||||||
|
return lines.subList(start + 1, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseProperties(lines: List<String>): Map<String, String> {
|
||||||
|
val result = mutableMapOf<String, String>()
|
||||||
|
for (line in lines) {
|
||||||
|
val colonIdx = line.indexOf(':')
|
||||||
|
if (colonIdx < 0) continue
|
||||||
|
val rawKey = line.substring(0, colonIdx).uppercase()
|
||||||
|
val value = line.substring(colonIdx + 1)
|
||||||
|
// Key may have parameters like DUE;TZID=UTC — use base key
|
||||||
|
val key = rawKey.substringBefore(';')
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.decodeIcal(): String =
|
||||||
|
replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
|
||||||
|
|
||||||
|
private fun parseDate(value: String): String? {
|
||||||
|
val v = value.substringAfterLast(':') // strip TZID param if any
|
||||||
|
return when {
|
||||||
|
v.length >= 8 -> "${v.take(4)}-${v.substring(4, 6)}-${v.substring(6, 8)}"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDateTime(value: String): String? {
|
||||||
|
val v = value.substringAfterLast(':')
|
||||||
|
if (v.length < 15) return null
|
||||||
|
return "${v.take(4)}-${v.substring(4, 6)}-${v.substring(6, 8)}T${v.substring(9, 11)}:${v.substring(11, 13)}:${v.substring(13, 15)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun icalPriorityToApp(p: Int): Int = when {
|
||||||
|
p == 1 -> 1
|
||||||
|
p in 2..4 -> 2
|
||||||
|
p in 5..6 -> 3
|
||||||
|
else -> 4
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.planify.mobile.data.local.dao.LabelDao
|
|||||||
import com.planify.mobile.data.local.dao.ProjectDao
|
import com.planify.mobile.data.local.dao.ProjectDao
|
||||||
import com.planify.mobile.data.local.dao.ReminderDao
|
import com.planify.mobile.data.local.dao.ReminderDao
|
||||||
import com.planify.mobile.data.local.dao.SectionDao
|
import com.planify.mobile.data.local.dao.SectionDao
|
||||||
|
import com.planify.mobile.data.local.dao.SourceDao
|
||||||
import com.planify.mobile.data.local.dao.TaskDao
|
import com.planify.mobile.data.local.dao.TaskDao
|
||||||
import com.planify.mobile.data.local.entity.LabelEntity
|
import com.planify.mobile.data.local.entity.LabelEntity
|
||||||
import com.planify.mobile.data.local.entity.ProjectEntity
|
import com.planify.mobile.data.local.entity.ProjectEntity
|
||||||
@@ -32,4 +33,5 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun sectionDao(): SectionDao
|
abstract fun sectionDao(): SectionDao
|
||||||
abstract fun labelDao(): LabelDao
|
abstract fun labelDao(): LabelDao
|
||||||
abstract fun reminderDao(): ReminderDao
|
abstract fun reminderDao(): ReminderDao
|
||||||
|
abstract fun sourceDao(): SourceDao
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.planify.mobile.data.local.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Update
|
||||||
|
import com.planify.mobile.data.local.entity.SourceEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface SourceDao {
|
||||||
|
@Query("SELECT * FROM sources ORDER BY added_at")
|
||||||
|
fun getAllSources(): Flow<List<SourceEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE id = :id")
|
||||||
|
suspend fun getSourceById(id: String): SourceEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE type = 'CALDAV'")
|
||||||
|
suspend fun getCaldavSources(): List<SourceEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(source: SourceEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(source: SourceEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM sources WHERE id = :id")
|
||||||
|
suspend fun delete(id: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.planify.mobile.data.repository
|
||||||
|
|
||||||
|
import com.planify.mobile.data.local.dao.SourceDao
|
||||||
|
import com.planify.mobile.data.local.entity.SourceEntity
|
||||||
|
import com.planify.mobile.domain.model.CalDavType
|
||||||
|
import com.planify.mobile.domain.model.Source
|
||||||
|
import com.planify.mobile.domain.model.SourceCalDavData
|
||||||
|
import com.planify.mobile.domain.model.SourceType
|
||||||
|
import com.planify.mobile.domain.repository.SourceRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class SourceRepositoryImpl @Inject constructor(
|
||||||
|
private val dao: SourceDao,
|
||||||
|
) : SourceRepository {
|
||||||
|
|
||||||
|
override fun getAllSources(): Flow<List<Source>> =
|
||||||
|
dao.getAllSources().map { list -> list.map { it.toDomain() } }
|
||||||
|
|
||||||
|
override suspend fun getSourceById(id: String): Source? =
|
||||||
|
dao.getSourceById(id)?.toDomain()
|
||||||
|
|
||||||
|
override suspend fun getCaldavSources(): List<Source> =
|
||||||
|
dao.getCaldavSources().map { it.toDomain() }
|
||||||
|
|
||||||
|
override suspend fun insertSource(source: Source) = dao.insert(source.toEntity())
|
||||||
|
|
||||||
|
override suspend fun updateSource(source: Source) = dao.update(source.toEntity())
|
||||||
|
|
||||||
|
override suspend fun deleteSource(id: String) = dao.delete(id)
|
||||||
|
|
||||||
|
private fun SourceEntity.toDomain(): Source {
|
||||||
|
val caldav = runCatching {
|
||||||
|
val obj = Json.parseToJsonElement(data).jsonObject
|
||||||
|
SourceCalDavData(
|
||||||
|
serverUrl = obj["serverUrl"]?.jsonPrimitive?.content ?: "",
|
||||||
|
username = obj["username"]?.jsonPrimitive?.content ?: "",
|
||||||
|
calendarHomeUrl = obj["calendarHomeUrl"]?.jsonPrimitive?.content,
|
||||||
|
userDisplayName = obj["userDisplayName"]?.jsonPrimitive?.content,
|
||||||
|
userEmail = obj["userEmail"]?.jsonPrimitive?.content,
|
||||||
|
caldavType = CalDavType.valueOf(obj["caldavType"]?.jsonPrimitive?.content ?: "GENERIC"),
|
||||||
|
ignoreSsl = obj["ignoreSsl"]?.jsonPrimitive?.content == "true",
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
return Source(
|
||||||
|
id = id,
|
||||||
|
type = SourceType.valueOf(type),
|
||||||
|
displayName = displayName,
|
||||||
|
addedAt = addedAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
isVisible = isVisible,
|
||||||
|
lastSync = lastSync,
|
||||||
|
caldavData = caldav,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Source.toEntity(): SourceEntity {
|
||||||
|
val dataJson = caldavData?.let { cd ->
|
||||||
|
buildString {
|
||||||
|
append("{")
|
||||||
|
append("\"serverUrl\":\"${cd.serverUrl}\",")
|
||||||
|
append("\"username\":\"${cd.username}\",")
|
||||||
|
cd.calendarHomeUrl?.let { append("\"calendarHomeUrl\":\"$it\",") }
|
||||||
|
cd.userDisplayName?.let { append("\"userDisplayName\":\"$it\",") }
|
||||||
|
cd.userEmail?.let { append("\"userEmail\":\"$it\",") }
|
||||||
|
append("\"caldavType\":\"${cd.caldavType.name}\",")
|
||||||
|
append("\"ignoreSsl\":\"${cd.ignoreSsl}\"")
|
||||||
|
append("}")
|
||||||
|
}
|
||||||
|
} ?: "{}"
|
||||||
|
return SourceEntity(
|
||||||
|
id = id,
|
||||||
|
type = type.name,
|
||||||
|
displayName = displayName,
|
||||||
|
addedAt = addedAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
isVisible = isVisible,
|
||||||
|
lastSync = lastSync,
|
||||||
|
data = dataJson,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.planify.mobile.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object CalDavModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideOkHttpClient(): OkHttpClient =
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.followRedirects(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -26,4 +26,5 @@ object DatabaseModule {
|
|||||||
@Provides fun provideSectionDao(db: AppDatabase) = db.sectionDao()
|
@Provides fun provideSectionDao(db: AppDatabase) = db.sectionDao()
|
||||||
@Provides fun provideLabelDao(db: AppDatabase) = db.labelDao()
|
@Provides fun provideLabelDao(db: AppDatabase) = db.labelDao()
|
||||||
@Provides fun provideReminderDao(db: AppDatabase) = db.reminderDao()
|
@Provides fun provideReminderDao(db: AppDatabase) = db.reminderDao()
|
||||||
|
@Provides fun provideSourceDao(db: AppDatabase) = db.sourceDao()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import com.planify.mobile.data.repository.LabelRepositoryImpl
|
|||||||
import com.planify.mobile.data.repository.ProjectRepositoryImpl
|
import com.planify.mobile.data.repository.ProjectRepositoryImpl
|
||||||
import com.planify.mobile.data.repository.ReminderRepositoryImpl
|
import com.planify.mobile.data.repository.ReminderRepositoryImpl
|
||||||
import com.planify.mobile.data.repository.SectionRepositoryImpl
|
import com.planify.mobile.data.repository.SectionRepositoryImpl
|
||||||
|
import com.planify.mobile.data.repository.SourceRepositoryImpl
|
||||||
import com.planify.mobile.data.repository.TaskRepositoryImpl
|
import com.planify.mobile.data.repository.TaskRepositoryImpl
|
||||||
import com.planify.mobile.domain.repository.LabelRepository
|
import com.planify.mobile.domain.repository.LabelRepository
|
||||||
import com.planify.mobile.domain.repository.ProjectRepository
|
import com.planify.mobile.domain.repository.ProjectRepository
|
||||||
import com.planify.mobile.domain.repository.ReminderRepository
|
import com.planify.mobile.domain.repository.ReminderRepository
|
||||||
import com.planify.mobile.domain.repository.SectionRepository
|
import com.planify.mobile.domain.repository.SectionRepository
|
||||||
|
import com.planify.mobile.domain.repository.SourceRepository
|
||||||
import com.planify.mobile.domain.repository.TaskRepository
|
import com.planify.mobile.domain.repository.TaskRepository
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
@@ -34,4 +36,7 @@ abstract class RepositoryModule {
|
|||||||
|
|
||||||
@Binds @Singleton
|
@Binds @Singleton
|
||||||
abstract fun bindReminderRepository(impl: ReminderRepositoryImpl): ReminderRepository
|
abstract fun bindReminderRepository(impl: ReminderRepositoryImpl): ReminderRepository
|
||||||
|
|
||||||
|
@Binds @Singleton
|
||||||
|
abstract fun bindSourceRepository(impl: SourceRepositoryImpl): SourceRepository
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.planify.mobile.domain.repository
|
||||||
|
|
||||||
|
import com.planify.mobile.domain.model.Source
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface SourceRepository {
|
||||||
|
fun getAllSources(): Flow<List<Source>>
|
||||||
|
suspend fun getSourceById(id: String): Source?
|
||||||
|
suspend fun getCaldavSources(): List<Source>
|
||||||
|
suspend fun insertSource(source: Source)
|
||||||
|
suspend fun updateSource(source: Source)
|
||||||
|
suspend fun deleteSource(id: String)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user