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,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
|
||||
}
|
||||
@@ -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.ReminderDao
|
||||
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.entity.LabelEntity
|
||||
import com.planify.mobile.data.local.entity.ProjectEntity
|
||||
@@ -32,4 +33,5 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun sectionDao(): SectionDao
|
||||
abstract fun labelDao(): LabelDao
|
||||
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 provideLabelDao(db: AppDatabase) = db.labelDao()
|
||||
@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.ReminderRepositoryImpl
|
||||
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.domain.repository.LabelRepository
|
||||
import com.planify.mobile.domain.repository.ProjectRepository
|
||||
import com.planify.mobile.domain.repository.ReminderRepository
|
||||
import com.planify.mobile.domain.repository.SectionRepository
|
||||
import com.planify.mobile.domain.repository.SourceRepository
|
||||
import com.planify.mobile.domain.repository.TaskRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
@@ -34,4 +36,7 @@ abstract class RepositoryModule {
|
||||
|
||||
@Binds @Singleton
|
||||
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