Initial import of f7cloud-mobile native Android app.
Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support). Current version: 0.5.113 (build 121).
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
@@ -0,0 +1,17 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class BasicAuthInterceptor(
|
||||
private val username: String,
|
||||
private val appPassword: String,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val req = chain.request().newBuilder()
|
||||
.header("Authorization", Credentials.basic(username, appPassword))
|
||||
.build()
|
||||
return chain.proceed(req)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.UUID
|
||||
import java.util.regex.Pattern
|
||||
|
||||
data class CalendarAttendeeData(
|
||||
val email: String,
|
||||
val displayName: String = "",
|
||||
val partStat: String = "NEEDS-ACTION",
|
||||
val role: String = "REQ-PARTICIPANT",
|
||||
val rsvp: Boolean = true,
|
||||
)
|
||||
|
||||
data class CalendarAlarmData(
|
||||
val minutesBefore: Int,
|
||||
val action: String = "DISPLAY",
|
||||
)
|
||||
|
||||
data class CalendarEventData(
|
||||
val uid: String,
|
||||
val summary: String,
|
||||
val description: String = "",
|
||||
val location: String = "",
|
||||
val startEpochMilli: Long,
|
||||
val endEpochMilli: Long,
|
||||
val allDay: Boolean = false,
|
||||
val rrule: String = "",
|
||||
val categories: List<String> = emptyList(),
|
||||
val status: String = "CONFIRMED",
|
||||
val classification: String = "PUBLIC",
|
||||
val attendees: List<CalendarAttendeeData> = emptyList(),
|
||||
val organizerEmail: String = "",
|
||||
val organizerName: String = "",
|
||||
val alarms: List<CalendarAlarmData> = emptyList(),
|
||||
val conferenceUri: String = "",
|
||||
)
|
||||
|
||||
object CalendarIcs {
|
||||
private val veventBlock = Pattern.compile("BEGIN:VEVENT([\\s\\S]*?)END:VEVENT", Pattern.CASE_INSENSITIVE)
|
||||
private val linePattern = Pattern.compile("^([A-Z0-9-]+)(?:;[^:]*)?:(.*)$", Pattern.MULTILINE)
|
||||
|
||||
fun parseAll(ics: String): List<CalendarEventData> {
|
||||
val unfolded = unfold(ics)
|
||||
val blocks = veventBlock.matcher(unfolded)
|
||||
val out = mutableListOf<CalendarEventData>()
|
||||
while (blocks.find()) {
|
||||
parseVEventBlock(blocks.group(1) ?: continue)?.let { out += it }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun parseSingle(ics: String): CalendarEventData? {
|
||||
return parseAll(ics).firstOrNull()
|
||||
}
|
||||
|
||||
fun build(data: CalendarEventData): String = buildString {
|
||||
appendLine("BEGIN:VCALENDAR")
|
||||
appendLine("VERSION:2.0")
|
||||
appendLine("PRODID:-//F7cloud Mobile//EN")
|
||||
appendLine("CALSCALE:GREGORIAN")
|
||||
append(serializeVEvent(data))
|
||||
appendLine("END:VCALENDAR")
|
||||
}
|
||||
|
||||
private fun parseVEventBlock(block: String): CalendarEventData? {
|
||||
val lines = parseLines(block)
|
||||
val uid = lines["UID"]?.trim().orEmpty()
|
||||
if (uid.isBlank()) return null
|
||||
val dtStartRaw = lines["DTSTART"].orEmpty()
|
||||
val start = parseIcsInstant(dtStartRaw) ?: return null
|
||||
val allDay = !dtStartRaw.contains('T')
|
||||
val endRaw = lines["DTEND"]
|
||||
val end = if (endRaw != null) {
|
||||
parseIcsInstant(endRaw) ?: start.plusSeconds(if (allDay) 86400 else 3600)
|
||||
} else {
|
||||
start.plusSeconds(if (allDay) 86400 else 3600)
|
||||
}
|
||||
val attendees = lines.entries
|
||||
.filter { it.key.startsWith("ATTENDEE") }
|
||||
.mapNotNull { parseAttendeeLine(it.key, it.value) }
|
||||
val organizer = lines["ORGANIZER"].orEmpty()
|
||||
val (orgEmail, orgName) = parseOrganizer(organizer)
|
||||
val alarms = parseAlarms(block)
|
||||
val conference = lines.entries
|
||||
.firstOrNull { it.key.startsWith("CONFERENCE") }
|
||||
?.value
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
val location = unescape(lines["LOCATION"].orEmpty())
|
||||
val talkUrl = conference.ifBlank {
|
||||
if (location.contains("/call/")) location else ""
|
||||
}
|
||||
return CalendarEventData(
|
||||
uid = uid,
|
||||
summary = unescape(lines["SUMMARY"].orEmpty()).ifBlank { "(без названия)" },
|
||||
description = unescape(lines["DESCRIPTION"].orEmpty()),
|
||||
location = location,
|
||||
startEpochMilli = start.toEpochMilli(),
|
||||
endEpochMilli = end.toEpochMilli(),
|
||||
allDay = allDay,
|
||||
rrule = lines["RRULE"].orEmpty(),
|
||||
categories = lines["CATEGORIES"]?.split(',')?.map { unescape(it.trim()) }?.filter { it.isNotBlank() }.orEmpty(),
|
||||
status = lines["STATUS"]?.trim().orEmpty().ifBlank { "CONFIRMED" },
|
||||
classification = lines["CLASS"]?.trim().orEmpty().ifBlank { "PUBLIC" },
|
||||
attendees = attendees,
|
||||
organizerEmail = orgEmail,
|
||||
organizerName = orgName,
|
||||
alarms = alarms,
|
||||
conferenceUri = talkUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun serializeVEvent(data: CalendarEventData): String = buildString {
|
||||
val zone = ZoneId.systemDefault()
|
||||
val tzId = zone.id.replace(":", "\\:")
|
||||
val now = Instant.now()
|
||||
appendLine("BEGIN:VEVENT")
|
||||
appendLine("UID:${data.uid}")
|
||||
appendLine("DTSTAMP:${formatUtc(now)}")
|
||||
val startZ = Instant.ofEpochMilli(data.startEpochMilli).atZone(zone)
|
||||
val endZ = Instant.ofEpochMilli(data.endEpochMilli).atZone(zone)
|
||||
if (data.allDay) {
|
||||
appendLine("DTSTART;VALUE=DATE:${startZ.format(DateTimeFormatter.BASIC_ISO_DATE)}")
|
||||
appendLine("DTEND;VALUE=DATE:${endZ.format(DateTimeFormatter.BASIC_ISO_DATE)}")
|
||||
} else {
|
||||
appendLine("DTSTART;TZID=$tzId:${formatLocal(startZ)}")
|
||||
appendLine("DTEND;TZID=$tzId:${formatLocal(endZ)}")
|
||||
}
|
||||
appendLine("SUMMARY:${escape(data.summary)}")
|
||||
if (data.description.isNotBlank()) appendLine("DESCRIPTION:${escape(data.description)}")
|
||||
if (data.location.isNotBlank()) appendLine("LOCATION:${escape(data.location)}")
|
||||
if (data.rrule.isNotBlank()) appendLine("RRULE:${data.rrule}")
|
||||
if (data.categories.isNotEmpty()) {
|
||||
appendLine("CATEGORIES:${data.categories.joinToString(",") { escape(it) }}")
|
||||
}
|
||||
appendLine("STATUS:${data.status}")
|
||||
appendLine("CLASS:${data.classification}")
|
||||
val orgEmail = data.organizerEmail
|
||||
if (orgEmail.isNotBlank()) {
|
||||
val cn = if (data.organizerName.isNotBlank()) ";CN=${escape(data.organizerName)}" else ""
|
||||
appendLine("ORGANIZER;CUTYPE=INDIVIDUAL$cn:mailto:$orgEmail")
|
||||
}
|
||||
data.attendees.forEach { attendee ->
|
||||
val cn = if (attendee.displayName.isNotBlank()) ";CN=${escape(attendee.displayName)}" else ""
|
||||
val rsvp = if (attendee.rsvp) ";RSVP=TRUE" else ""
|
||||
appendLine(
|
||||
"ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=${attendee.role};PARTSTAT=${attendee.partStat}$rsvp$cn:mailto:${attendee.email}",
|
||||
)
|
||||
}
|
||||
val talk = data.conferenceUri.ifBlank {
|
||||
if (data.location.contains("/call/")) data.location else ""
|
||||
}
|
||||
if (talk.isNotBlank()) {
|
||||
appendLine("CONFERENCE;FEATURE=PHONE,VIDEO;VALUE=URI:$talk")
|
||||
if (data.location.isBlank()) appendLine("LOCATION:$talk")
|
||||
}
|
||||
data.alarms.forEach { alarm ->
|
||||
appendLine("BEGIN:VALARM")
|
||||
appendLine("ACTION:${alarm.action}")
|
||||
appendLine("TRIGGER:-PT${alarm.minutesBefore}M")
|
||||
appendLine("DESCRIPTION:${escape(data.summary)}")
|
||||
appendLine("END:VALARM")
|
||||
}
|
||||
appendLine("END:VEVENT")
|
||||
}
|
||||
|
||||
fun newUid(): String = "${UUID.randomUUID()}@f7cloud.mobile"
|
||||
|
||||
fun parseIcsInstant(raw: String): Instant? {
|
||||
val value = raw.trim()
|
||||
if (value.isBlank()) return null
|
||||
return runCatching {
|
||||
when {
|
||||
value.contains('T') -> {
|
||||
val clean = value.replace("Z", "", ignoreCase = true).take(15)
|
||||
LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
|
||||
.atZone(ZoneId.systemDefault()).toInstant()
|
||||
}
|
||||
value.length >= 8 -> {
|
||||
LocalDate.parse(value.take(8), DateTimeFormatter.BASIC_ISO_DATE)
|
||||
.atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun parseLines(block: String): Map<String, String> {
|
||||
val map = mutableMapOf<String, String>()
|
||||
unfold(block).lineSequence().forEach { line ->
|
||||
val m = linePattern.matcher(line.trim())
|
||||
if (m.find()) {
|
||||
val key = m.group(1)?.uppercase().orEmpty()
|
||||
val value = m.group(2).orEmpty()
|
||||
map[key] = if (map.containsKey(key)) "${map[key]}\n$value" else value
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private fun parseAttendeeLine(key: String, value: String): CalendarAttendeeData? {
|
||||
val email = value.substringAfter("mailto:", value).trim()
|
||||
if (email.isBlank()) return null
|
||||
val cn = Regex("CN=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1)?.let(::unescape)
|
||||
val partStat = Regex("PARTSTAT=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "NEEDS-ACTION"
|
||||
val role = Regex("ROLE=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "REQ-PARTICIPANT"
|
||||
val rsvp = !key.contains("RSVP=FALSE", ignoreCase = true)
|
||||
return CalendarAttendeeData(email = email, displayName = cn.orEmpty(), partStat = partStat, role = role, rsvp = rsvp)
|
||||
}
|
||||
|
||||
private fun parseOrganizer(value: String): Pair<String, String> {
|
||||
val email = value.substringAfter("mailto:", value).trim()
|
||||
val cn = Regex("CN=([^;:]+)", RegexOption.IGNORE_CASE).find(value)?.groupValues?.get(1)?.let(::unescape).orEmpty()
|
||||
return email to cn
|
||||
}
|
||||
|
||||
private fun parseAlarms(block: String): List<CalendarAlarmData> {
|
||||
val alarmPattern = Pattern.compile("BEGIN:VALARM([\\s\\S]*?)END:VALARM", Pattern.CASE_INSENSITIVE)
|
||||
val matcher = alarmPattern.matcher(block)
|
||||
val out = mutableListOf<CalendarAlarmData>()
|
||||
while (matcher.find()) {
|
||||
val lines = parseLines(matcher.group(1).orEmpty())
|
||||
val trigger = lines["TRIGGER"].orEmpty()
|
||||
val minutes = parseTriggerMinutes(trigger)
|
||||
if (minutes != null) {
|
||||
out += CalendarAlarmData(minutesBefore = minutes, action = lines["ACTION"] ?: "DISPLAY")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseTriggerMinutes(trigger: String): Int? {
|
||||
val t = trigger.trim()
|
||||
val relative = Regex("-PT(\\d+)M", RegexOption.IGNORE_CASE).find(t)?.groupValues?.get(1)?.toIntOrNull()
|
||||
if (relative != null) return relative
|
||||
val hours = Regex("-PT(\\d+)H", RegexOption.IGNORE_CASE).find(t)?.groupValues?.get(1)?.toIntOrNull()
|
||||
if (hours != null) return hours * 60
|
||||
val days = Regex("-P(\\d+)D", RegexOption.IGNORE_CASE).find(t)?.groupValues?.get(1)?.toIntOrNull()
|
||||
if (days != null) return days * 24 * 60
|
||||
return null
|
||||
}
|
||||
|
||||
private fun unfold(raw: String): String {
|
||||
val normalized = raw.replace("\r\n", "\n").replace('\r', '\n')
|
||||
val lines = normalized.split('\n')
|
||||
val unfolded = StringBuilder()
|
||||
for (line in lines) {
|
||||
if (line.startsWith(' ') || line.startsWith('\t')) {
|
||||
if (unfolded.isNotEmpty()) unfolded.append(line.drop(1))
|
||||
} else {
|
||||
if (unfolded.isNotEmpty()) unfolded.append('\n')
|
||||
unfolded.append(line)
|
||||
}
|
||||
}
|
||||
return unfolded.toString()
|
||||
}
|
||||
|
||||
private fun escape(text: String): String =
|
||||
text.replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
|
||||
|
||||
private fun unescape(text: String): String =
|
||||
text.replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
|
||||
|
||||
private fun formatUtc(instant: Instant): String =
|
||||
instant.atZone(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'"))
|
||||
|
||||
private fun formatLocal(zoned: ZonedDateTime): String =
|
||||
zoned.format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.net.URLDecoder
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
import java.util.regex.Pattern
|
||||
|
||||
data class DavContact(
|
||||
val uid: String,
|
||||
val displayName: String,
|
||||
val email: String,
|
||||
val phone: String,
|
||||
val bookName: String,
|
||||
val photoBase64: String = "",
|
||||
val photoMimeType: String = "",
|
||||
val organization: String = "",
|
||||
val title: String = "",
|
||||
val address: String = "",
|
||||
val website: String = "",
|
||||
val birthday: String = "",
|
||||
val emails: String = "",
|
||||
val phones: String = "",
|
||||
)
|
||||
|
||||
object CardDavClient {
|
||||
private val fnPattern = Pattern.compile("FN[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
private val nPattern = Pattern.compile("N[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
private val emailPattern = Pattern.compile("EMAIL[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
private val telPattern = Pattern.compile("TEL[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
private val uidPattern = Pattern.compile("UID[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
private val orgPattern = Pattern.compile("ORG[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
private val titlePattern = Pattern.compile("TITLE[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
private val urlPattern = Pattern.compile("URL[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
private val adrPattern = Pattern.compile("ADR[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
private val bdayPattern = Pattern.compile("BDAY[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
private val genericNames = setOf(
|
||||
"contact",
|
||||
"carddav",
|
||||
"card dav",
|
||||
"unknown",
|
||||
"vcard",
|
||||
)
|
||||
|
||||
fun listContacts(
|
||||
client: OkHttpClient,
|
||||
serverUrl: String,
|
||||
userId: String,
|
||||
limitPerBook: Int = 500,
|
||||
): List<DavContact> {
|
||||
val base = davAddressBooksBaseUrl(serverUrl, userId)
|
||||
val books = listAddressBooks(client, base)
|
||||
val out = mutableListOf<DavContact>()
|
||||
var fetchFailed = false
|
||||
for (book in books) {
|
||||
runCatching { fetchContactsFromBook(client, book, limitPerBook) }
|
||||
.onSuccess { out += it }
|
||||
.onFailure { fetchFailed = true }
|
||||
}
|
||||
if (out.isEmpty() && fetchFailed) {
|
||||
error("Не удалось загрузить контакты")
|
||||
}
|
||||
return out.distinctBy { "${it.uid}|${it.email}" }
|
||||
}
|
||||
|
||||
fun createContact(
|
||||
client: OkHttpClient,
|
||||
serverUrl: String,
|
||||
userId: String,
|
||||
displayName: String,
|
||||
email: String,
|
||||
phone: String = "",
|
||||
): DavContact {
|
||||
val base = davAddressBooksBaseUrl(serverUrl, userId)
|
||||
val books = listAddressBooks(client, base)
|
||||
val book = books.firstOrNull()
|
||||
?: error("Не найдена адресная книга")
|
||||
val (bookUrl, bookName) = book
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val vcard = buildVCard(uid, displayName, email, phone)
|
||||
val url = bookUrl.trimEnd('/') + "/$uid.vcf"
|
||||
val req = Request.Builder()
|
||||
.url(url)
|
||||
.put(vcard.toRequestBody("text/vcard; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("Не удалось создать контакт (HTTP ${response.code})")
|
||||
}
|
||||
}
|
||||
return DavContact(
|
||||
uid = uid,
|
||||
displayName = displayName.trim(),
|
||||
email = email.trim(),
|
||||
phone = phone.trim(),
|
||||
bookName = bookName,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildVCard(uid: String, displayName: String, email: String, phone: String): String {
|
||||
val fn = displayName.trim()
|
||||
val parts = fn.split(Regex("\\s+"), limit = 2)
|
||||
val given = parts.getOrNull(0).orEmpty()
|
||||
val family = parts.getOrNull(1).orEmpty()
|
||||
return buildString {
|
||||
append("BEGIN:VCARD\n")
|
||||
append("VERSION:3.0\n")
|
||||
append("UID:$uid\n")
|
||||
append("FN:$fn\n")
|
||||
append("N:$family;$given;;;\n")
|
||||
if (email.isNotBlank()) {
|
||||
append("EMAIL;TYPE=INTERNET:$email\n")
|
||||
}
|
||||
if (phone.isNotBlank()) {
|
||||
append("TEL;TYPE=CELL:$phone\n")
|
||||
}
|
||||
append("END:VCARD\n")
|
||||
}
|
||||
}
|
||||
|
||||
private fun listAddressBooks(client: OkHttpClient, baseUrl: String): List<Pair<String, String>> {
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:prop><d:displayname/><d:resourcetype/></d:prop>
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, baseUrl, depth = 1, body)
|
||||
val parsed = parseAddressBooks(xml, baseUrl)
|
||||
return parsed.ifEmpty {
|
||||
listOf(resolveHref(baseUrl, "contacts") to "Contacts")
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchContactsFromBook(
|
||||
client: OkHttpClient,
|
||||
book: Pair<String, String>,
|
||||
limit: Int,
|
||||
): List<DavContact> {
|
||||
val (href, bookName) = book
|
||||
val url = href.trimEnd('/') + "/"
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:prop><d:getetag/><card:address-data/></d:prop>
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, url, depth = 1, body)
|
||||
return parseContacts(xml, bookName).take(limit)
|
||||
}
|
||||
|
||||
private fun propfind(client: OkHttpClient, url: String, depth: Int, body: String): String {
|
||||
val req = Request.Builder()
|
||||
.url(url)
|
||||
.header("Depth", depth.toString())
|
||||
.method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
val code = response.code
|
||||
if (code !in 200..299 && code != 207) {
|
||||
error("CardDAV error HTTP $code")
|
||||
}
|
||||
val xml = response.body?.string().orEmpty()
|
||||
if (xml.isBlank()) {
|
||||
error("CardDAV empty response")
|
||||
}
|
||||
return xml
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAddressBooks(xml: String, baseUrl: String): List<Pair<String, String>> {
|
||||
val parser = newParser(xml)
|
||||
val out = mutableListOf<Pair<String, String>>()
|
||||
val basePath = baseUrl.toDavPath()
|
||||
var inResponse = false
|
||||
var href = ""
|
||||
var displayName = ""
|
||||
var isCollection = false
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> when (parser.localTag()) {
|
||||
"response" -> {
|
||||
inResponse = true
|
||||
href = ""
|
||||
displayName = ""
|
||||
isCollection = false
|
||||
}
|
||||
"collection" -> if (inResponse) isCollection = true
|
||||
"displayname" -> if (inResponse) displayName = parser.readText().trim()
|
||||
"href" -> if (inResponse) href = parser.readText().trim()
|
||||
}
|
||||
XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) {
|
||||
if (isCollection && href.isNotBlank()) {
|
||||
val full = resolveHref(baseUrl, href)
|
||||
val fullPath = full.toDavPath()
|
||||
if (fullPath != basePath &&
|
||||
fullPath.startsWith(basePath) &&
|
||||
!fullPath.contains("/system/")
|
||||
) {
|
||||
val name = displayName.ifBlank {
|
||||
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
|
||||
}
|
||||
out += full to name
|
||||
}
|
||||
}
|
||||
inResponse = false
|
||||
}
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseContacts(xml: String, bookName: String): List<DavContact> {
|
||||
val parser = newParser(xml)
|
||||
val out = mutableListOf<DavContact>()
|
||||
var inAddressData = false
|
||||
val data = StringBuilder()
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> if (parser.localTag() == "address-data") {
|
||||
inAddressData = true
|
||||
data.clear()
|
||||
}
|
||||
XmlPullParser.TEXT -> if (inAddressData) data.append(parser.text)
|
||||
XmlPullParser.END_TAG -> if (parser.localTag() == "address-data" && inAddressData) {
|
||||
val vcard = unfoldVCard(data.toString())
|
||||
val uid = extract(uidPattern, vcard).ifBlank {
|
||||
vcard.hashCode().toString()
|
||||
}
|
||||
val name = resolveContactName(vcard)
|
||||
val allEmails = extractAll(emailPattern, vcard)
|
||||
val allPhones = extractAll(telPattern, vcard)
|
||||
val email = allEmails.firstOrNull().orEmpty()
|
||||
val phone = allPhones.firstOrNull().orEmpty()
|
||||
val photo = parseContactPhoto(vcard)
|
||||
if (name.isNotBlank() || email.isNotBlank()) {
|
||||
out += DavContact(
|
||||
uid = uid,
|
||||
displayName = name,
|
||||
email = email,
|
||||
phone = phone,
|
||||
bookName = bookName,
|
||||
photoBase64 = photo?.base64.orEmpty(),
|
||||
photoMimeType = photo?.mimeType.orEmpty(),
|
||||
organization = formatOrganization(extract(orgPattern, vcard)),
|
||||
title = extract(titlePattern, vcard),
|
||||
address = formatAddress(extract(adrPattern, vcard)),
|
||||
website = extract(urlPattern, vcard),
|
||||
birthday = formatBirthday(extract(bdayPattern, vcard)),
|
||||
emails = allEmails.joinToString("\n"),
|
||||
phones = allPhones.joinToString("\n"),
|
||||
)
|
||||
}
|
||||
inAddressData = false
|
||||
}
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun unfoldVCard(raw: String): String {
|
||||
val normalized = raw.replace("\r\n", "\n").replace('\r', '\n')
|
||||
val lines = normalized.split('\n')
|
||||
val unfolded = StringBuilder()
|
||||
for (line in lines) {
|
||||
if (line.startsWith(' ') || line.startsWith('\t')) {
|
||||
if (unfolded.isNotEmpty()) {
|
||||
unfolded.append(line.drop(1))
|
||||
}
|
||||
} else {
|
||||
if (unfolded.isNotEmpty()) unfolded.append('\n')
|
||||
unfolded.append(line)
|
||||
}
|
||||
}
|
||||
return unfolded.toString()
|
||||
}
|
||||
|
||||
private fun resolveHref(baseUrl: String, href: String): String {
|
||||
if (href.startsWith("http")) return href
|
||||
if (href.startsWith("/")) {
|
||||
val server = baseUrl.substringBefore("/remote.php")
|
||||
return server + href
|
||||
}
|
||||
return baseUrl.trimEnd('/') + "/" + href.trimStart('/')
|
||||
}
|
||||
|
||||
private fun extract(pattern: Pattern, text: String): String {
|
||||
val m = pattern.matcher(text)
|
||||
return if (m.find()) m.group(1)?.trim().orEmpty() else ""
|
||||
}
|
||||
|
||||
private fun extractAll(pattern: Pattern, text: String): List<String> {
|
||||
val m = pattern.matcher(text)
|
||||
val out = mutableListOf<String>()
|
||||
while (m.find()) {
|
||||
m.group(1)?.trim()?.takeIf { it.isNotBlank() }?.let { out += it }
|
||||
}
|
||||
return out.distinct()
|
||||
}
|
||||
|
||||
private fun formatOrganization(raw: String): String {
|
||||
if (raw.isBlank()) return ""
|
||||
return raw.split(';').firstOrNull()?.trim().orEmpty()
|
||||
}
|
||||
|
||||
private fun formatAddress(raw: String): String {
|
||||
if (raw.isBlank()) return ""
|
||||
val parts = raw.split(';')
|
||||
return listOf(
|
||||
parts.getOrNull(2).orEmpty(),
|
||||
parts.getOrNull(3).orEmpty(),
|
||||
parts.getOrNull(4).orEmpty(),
|
||||
parts.getOrNull(5).orEmpty(),
|
||||
parts.getOrNull(6).orEmpty(),
|
||||
)
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(", ")
|
||||
}
|
||||
|
||||
private fun formatBirthday(raw: String): String {
|
||||
val value = raw.trim()
|
||||
if (value.isBlank()) return ""
|
||||
if (value.length == 8 && value.all { it.isDigit() }) {
|
||||
return "${value.substring(6, 8)}.${value.substring(4, 6)}.${value.substring(0, 4)}"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun resolveContactName(vcard: String): String {
|
||||
val fn = extract(fnPattern, vcard)
|
||||
if (fn.isNotBlank() && !isGenericName(fn)) return fn
|
||||
val nRaw = extract(nPattern, vcard)
|
||||
if (nRaw.isNotBlank()) {
|
||||
val parts = nRaw.split(';')
|
||||
val family = parts.getOrNull(0).orEmpty().trim()
|
||||
val given = parts.getOrNull(1).orEmpty().trim()
|
||||
val additional = parts.getOrNull(2).orEmpty().trim()
|
||||
val composed = listOf(given, additional, family)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
if (composed.isNotBlank() && !isGenericName(composed)) return composed
|
||||
}
|
||||
val email = extract(emailPattern, vcard)
|
||||
if (email.isNotBlank()) return email.substringBefore('@')
|
||||
return fn.ifBlank { "" }
|
||||
}
|
||||
|
||||
private fun isGenericName(name: String): Boolean {
|
||||
val normalized = name.trim().lowercase()
|
||||
if (normalized.isBlank()) return true
|
||||
return genericNames.contains(normalized)
|
||||
}
|
||||
|
||||
private data class ParsedContactPhoto(
|
||||
val base64: String,
|
||||
val mimeType: String,
|
||||
)
|
||||
|
||||
private fun parseContactPhoto(vcard: String): ParsedContactPhoto? {
|
||||
val line = vcard.lineSequence()
|
||||
.firstOrNull { it.startsWith("PHOTO", ignoreCase = true) }
|
||||
?: return null
|
||||
val colon = line.indexOf(':')
|
||||
if (colon < 0) return null
|
||||
val header = line.substring(0, colon)
|
||||
val payload = line.substring(colon + 1).trim()
|
||||
if (payload.isBlank()) return null
|
||||
|
||||
if (payload.startsWith("data:", ignoreCase = true)) {
|
||||
val mime = payload.substringAfter("data:", "")
|
||||
.substringBefore(';')
|
||||
.ifBlank { "image/jpeg" }
|
||||
val base64 = payload.substringAfter("base64,", "")
|
||||
return encodePhotoBase64(base64, mime)
|
||||
}
|
||||
if (header.contains("VALUE=URI", ignoreCase = true)) {
|
||||
return null
|
||||
}
|
||||
val mime = when {
|
||||
header.contains("TYPE=PNG", ignoreCase = true) ||
|
||||
header.contains("MEDIATYPE=image/png", ignoreCase = true) -> "image/png"
|
||||
header.contains("TYPE=GIF", ignoreCase = true) ||
|
||||
header.contains("MEDIATYPE=image/gif", ignoreCase = true) -> "image/gif"
|
||||
header.contains("TYPE=WEBP", ignoreCase = true) ||
|
||||
header.contains("MEDIATYPE=image/webp", ignoreCase = true) -> "image/webp"
|
||||
else -> "image/jpeg"
|
||||
}
|
||||
return encodePhotoBase64(payload, mime)
|
||||
}
|
||||
|
||||
private fun encodePhotoBase64(raw: String, mimeType: String): ParsedContactPhoto? {
|
||||
val normalized = raw.replace("\\s".toRegex(), "")
|
||||
if (normalized.isBlank()) return null
|
||||
val bytes = runCatching {
|
||||
Base64.getDecoder().decode(normalized)
|
||||
}.getOrNull() ?: return null
|
||||
if (bytes.isEmpty()) return null
|
||||
return ParsedContactPhoto(
|
||||
base64 = Base64.getEncoder().encodeToString(bytes),
|
||||
mimeType = mimeType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun newParser(xml: String): XmlPullParser {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
parser.setInput(xml.reader())
|
||||
return parser
|
||||
}
|
||||
|
||||
private fun XmlPullParser.localTag(): String = name.substringAfter(':')
|
||||
|
||||
private fun XmlPullParser.readText(): String {
|
||||
if (next() != XmlPullParser.TEXT) return ""
|
||||
return text.orEmpty()
|
||||
}
|
||||
|
||||
private fun String.toDavPath(): String {
|
||||
val path = if (contains("://")) {
|
||||
java.net.URI(this).path.orEmpty()
|
||||
} else {
|
||||
this
|
||||
}
|
||||
return try {
|
||||
URLDecoder.decode(path, Charsets.UTF_8.name()).trim('/')
|
||||
} catch (_: Exception) {
|
||||
path.trim('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.net.URLDecoder
|
||||
|
||||
object DavClient {
|
||||
data class DavEntry(
|
||||
val name: String,
|
||||
val href: String,
|
||||
val isDirectory: Boolean,
|
||||
val fileId: Long? = null,
|
||||
val lastModified: Long? = null,
|
||||
val size: Long? = null,
|
||||
val mimeType: String? = null,
|
||||
val favorite: Boolean = false,
|
||||
)
|
||||
|
||||
fun mkcol(client: OkHttpClient, folderUrl: String) {
|
||||
val request = Request.Builder()
|
||||
.url(folderUrl)
|
||||
.method("MKCOL", null)
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) {
|
||||
throw UnauthorizedException()
|
||||
}
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("DAV MKCOL HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun put(
|
||||
client: OkHttpClient,
|
||||
fileUrl: String,
|
||||
body: RequestBody,
|
||||
) {
|
||||
val request = Request.Builder()
|
||||
.url(fileUrl)
|
||||
.put(body)
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) {
|
||||
throw UnauthorizedException()
|
||||
}
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("DAV upload HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun propfind(client: OkHttpClient, folderUrl: String, depth: Int = 1): List<DavEntry> {
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:resourcetype/>
|
||||
<d:getlastmodified/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<oc:fileid/>
|
||||
<oc:favorite/>
|
||||
</d:prop>
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val request = Request.Builder()
|
||||
.url(folderUrl)
|
||||
.header("Depth", depth.toString())
|
||||
.method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) {
|
||||
throw UnauthorizedException()
|
||||
}
|
||||
val code = response.code
|
||||
if (code !in 200..299 && code != 207) {
|
||||
error("DAV error HTTP $code")
|
||||
}
|
||||
val xml = response.body?.string().orEmpty()
|
||||
if (xml.isBlank()) {
|
||||
error("DAV empty response")
|
||||
}
|
||||
parseMultiStatus(xml, folderUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMultiStatus(xml: String, folderUrl: String): List<DavEntry> {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
parser.setInput(xml.reader())
|
||||
|
||||
val folderPath = folderUrl.toDavPath()
|
||||
val result = mutableListOf<DavEntry>()
|
||||
|
||||
var inResponse = false
|
||||
var href = ""
|
||||
var displayName = ""
|
||||
var isCollection = false
|
||||
var fileId: Long? = null
|
||||
var lastModified: Long? = null
|
||||
var size: Long? = null
|
||||
var mimeType: String? = null
|
||||
var favorite = false
|
||||
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
when (parser.localTag()) {
|
||||
"response" -> {
|
||||
inResponse = true
|
||||
href = ""
|
||||
displayName = ""
|
||||
isCollection = false
|
||||
fileId = null
|
||||
lastModified = null
|
||||
size = null
|
||||
mimeType = null
|
||||
favorite = false
|
||||
}
|
||||
"href" -> if (inResponse) href = parser.readText()
|
||||
"displayname" -> if (inResponse) displayName = parser.readText()
|
||||
"collection" -> if (inResponse) isCollection = true
|
||||
"getlastmodified" -> if (inResponse) {
|
||||
lastModified = parseHttpDate(parser.readText())
|
||||
}
|
||||
"getcontentlength" -> if (inResponse) {
|
||||
parser.readText().toLongOrNull()?.let { size = it }
|
||||
}
|
||||
"getcontenttype" -> if (inResponse) {
|
||||
mimeType = parser.readText().trim().ifBlank { null }
|
||||
}
|
||||
"fileid" -> if (inResponse) {
|
||||
parser.readText().toLongOrNull()?.let { fileId = it }
|
||||
}
|
||||
"favorite" -> if (inResponse) {
|
||||
favorite = parser.readText().trim() == "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
if (parser.localTag() == "response" && inResponse) {
|
||||
inResponse = false
|
||||
val entryPath = href.decodeHrefPath()
|
||||
if (entryPath.isNotBlank() && entryPath != folderPath && entryPath.startsWith(folderPath)) {
|
||||
val relative = entryPath.removePrefix(folderPath).trim('/')
|
||||
if (relative.isNotBlank() && !relative.contains('/')) {
|
||||
val name = displayName.trim().ifBlank {
|
||||
relative.substringAfterLast('/')
|
||||
}
|
||||
if (name.isNotBlank() && name != "." && name != "..") {
|
||||
result += DavEntry(
|
||||
name = name,
|
||||
href = href,
|
||||
isDirectory = isCollection,
|
||||
fileId = fileId,
|
||||
lastModified = lastModified,
|
||||
size = size,
|
||||
mimeType = mimeType,
|
||||
favorite = favorite,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
return result.sortedWith(compareByDescending<DavEntry> { it.isDirectory }.thenBy { it.name.lowercase() })
|
||||
}
|
||||
|
||||
fun delete(client: OkHttpClient, resourceUrl: String) {
|
||||
val request = Request.Builder()
|
||||
.url(resourceUrl)
|
||||
.delete()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 204) {
|
||||
error("DAV DELETE HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun move(client: OkHttpClient, sourceUrl: String, destinationUrl: String) {
|
||||
val request = Request.Builder()
|
||||
.url(sourceUrl)
|
||||
.method("MOVE", null)
|
||||
.header("Destination", destinationUrl)
|
||||
.header("Overwrite", "T")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("DAV MOVE HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setFavorite(client: OkHttpClient, resourceUrl: String, favorite: Boolean) {
|
||||
val value = if (favorite) "1" else "0"
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<oc:favorite>$value</oc:favorite>
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</d:propertyupdate>
|
||||
""".trimIndent()
|
||||
val request = Request.Builder()
|
||||
.url(resourceUrl)
|
||||
.method("PROPPATCH", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 207) {
|
||||
error("DAV PROPPATCH HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHttpDate(raw: String): Long? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isBlank()) return null
|
||||
return try {
|
||||
val format = java.text.SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", java.util.Locale.US)
|
||||
format.parse(trimmed)?.time
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun XmlPullParser.localTag(): String = name.substringAfter(':')
|
||||
|
||||
private fun XmlPullParser.readText(): String {
|
||||
if (next() != XmlPullParser.TEXT) return ""
|
||||
return text.orEmpty()
|
||||
}
|
||||
|
||||
private fun String.decodeHrefPath(): String {
|
||||
val path = try {
|
||||
URLDecoder.decode(this, Charsets.UTF_8.name())
|
||||
} catch (_: Exception) {
|
||||
this
|
||||
}
|
||||
return path.substringBefore('?').trim('/')
|
||||
}
|
||||
|
||||
private fun String.toDavPath(): String {
|
||||
val path = if (contains("://")) {
|
||||
java.net.URI(this).path.orEmpty()
|
||||
} else {
|
||||
this
|
||||
}
|
||||
return path.decodeHrefPath()
|
||||
}
|
||||
}
|
||||
|
||||
fun davFilesBaseUrl(serverUrl: String, userId: String): String {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
val encodedUser = userId.split('/').joinToString("/") { segment ->
|
||||
java.net.URLEncoder.encode(segment, Charsets.UTF_8.name()).replace("+", "%20")
|
||||
}
|
||||
return "$base/remote.php/dav/files/$encodedUser/"
|
||||
}
|
||||
|
||||
fun davFolderUrl(serverUrl: String, userId: String, relativePath: String): String {
|
||||
val base = davFilesBaseUrl(serverUrl, userId)
|
||||
if (relativePath.isBlank()) return base
|
||||
val encodedPath = relativePath.trim('/').split('/').joinToString("/") { segment ->
|
||||
java.net.URLEncoder.encode(segment, Charsets.UTF_8.name()).replace("+", "%20")
|
||||
}
|
||||
return "$base$encodedPath/"
|
||||
}
|
||||
|
||||
fun davFileUrl(serverUrl: String, userId: String, relativePath: String): String {
|
||||
val base = davFilesBaseUrl(serverUrl, userId)
|
||||
if (relativePath.isBlank()) error("Путь к файлу пуст")
|
||||
val encodedPath = relativePath.trim('/').split('/').joinToString("/") { segment ->
|
||||
java.net.URLEncoder.encode(segment, Charsets.UTF_8.name()).replace("+", "%20")
|
||||
}
|
||||
return "$base$encodedPath"
|
||||
}
|
||||
|
||||
fun davAddressBooksBaseUrl(serverUrl: String, userId: String): String {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
val encodedUser = userId.split('/').joinToString("/") { segment ->
|
||||
java.net.URLEncoder.encode(segment, Charsets.UTF_8.name()).replace("+", "%20")
|
||||
}
|
||||
return "$base/remote.php/dav/addressbooks/users/$encodedUser/"
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.net.URLDecoder
|
||||
|
||||
data class QrLoginResult(
|
||||
val serverUrl: String,
|
||||
val username: String,
|
||||
val appPassword: String,
|
||||
)
|
||||
|
||||
object LoginFlowClient {
|
||||
/** F7cloud mobile QR scheme. */
|
||||
const val F7_LOGIN_PREFIX = "f7://login/"
|
||||
const val F7_OTP_PREFIX = "f7://onetime-login/"
|
||||
|
||||
/** Browser login flow v2 landing URL prefix (QR on web login page). */
|
||||
private val BROWSER_FLOW_REGEX = Regex("""/login/v2/flow/([A-Za-z0-9]+)""")
|
||||
|
||||
/** True when the scanned text looks like a complete F7 or browser login QR payload. */
|
||||
fun isCompleteQrPayload(qrData: String): Boolean {
|
||||
val trimmed = qrData.trim()
|
||||
return when {
|
||||
trimmed.startsWith(F7_LOGIN_PREFIX) || trimmed.startsWith(F7_OTP_PREFIX) ->
|
||||
parseCredentialParams(extractParams(trimmed)) != null
|
||||
isBrowserLoginFlowUrl(trimmed) ->
|
||||
(trimmed.startsWith("http://", ignoreCase = true) ||
|
||||
trimmed.startsWith("https://", ignoreCase = true)) &&
|
||||
BROWSER_FLOW_REGEX.containsMatchIn(trimmed)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun completeQrLogin(qrData: String, trustAllCerts: Boolean = false): QrLoginResult? =
|
||||
withContext(Dispatchers.IO) {
|
||||
when {
|
||||
isBrowserLoginFlowUrl(qrData) -> null
|
||||
else -> when (val payload = extractPayload(qrData)) {
|
||||
null -> null
|
||||
QrPayload.DirectLogin -> parseDirectLogin(extractParams(qrData))
|
||||
QrPayload.OneTimeLogin -> parseOneTimeLogin(extractParams(qrData), trustAllCerts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve browser login after scanning Login Flow v2 QR (logged-in mobile user).
|
||||
*/
|
||||
suspend fun approveBrowserLoginFromQr(
|
||||
qrData: String,
|
||||
username: String,
|
||||
appPassword: String,
|
||||
trustAllCerts: Boolean = false,
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
val flowUrl = qrData.trim()
|
||||
val landingUrl = normalizeBrowserFlowUrl(flowUrl) ?: return@withContext false
|
||||
val cookies = mutableListOf<Cookie>()
|
||||
val cookieJar = object : CookieJar {
|
||||
override fun saveFromResponse(url: HttpUrl, list: List<Cookie>) {
|
||||
cookies.removeAll { existing ->
|
||||
list.any { it.name == existing.name && it.domain == existing.domain && it.path == existing.path }
|
||||
}
|
||||
cookies.addAll(list)
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> =
|
||||
cookies.filter { it.matches(url) }
|
||||
}
|
||||
val client = OkHttpClient.Builder()
|
||||
.cookieJar(cookieJar)
|
||||
.followRedirects(true)
|
||||
.applyUnsafeSslIfNeeded(trustAllCerts)
|
||||
.build()
|
||||
|
||||
val landingHtml = client.newCall(
|
||||
Request.Builder().url(landingUrl).get().build(),
|
||||
).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext false
|
||||
response.body?.string().orEmpty()
|
||||
}
|
||||
|
||||
val stateToken = STATE_TOKEN_REGEX.find(landingHtml)?.groupValues?.get(1)?.trim().orEmpty()
|
||||
if (stateToken.isBlank()) return@withContext false
|
||||
|
||||
val requestToken = REQUEST_TOKEN_REGEX.find(landingHtml)?.groupValues?.get(1)?.trim().orEmpty()
|
||||
val base = extractServerBase(landingUrl)
|
||||
val apptokenUrl = "$base/login/v2/apptoken"
|
||||
val form = FormBody.Builder()
|
||||
.add("stateToken", stateToken)
|
||||
.add("user", username)
|
||||
.add("password", appPassword)
|
||||
if (requestToken.isNotBlank()) {
|
||||
form.add("requesttoken", requestToken)
|
||||
}
|
||||
|
||||
client.newCall(
|
||||
Request.Builder()
|
||||
.url(apptokenUrl)
|
||||
.post(form.build())
|
||||
.build(),
|
||||
).execute().use { response ->
|
||||
response.isSuccessful || response.code == 200
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBrowserLoginFlowUrl(qrData: String): Boolean =
|
||||
BROWSER_FLOW_REGEX.containsMatchIn(qrData)
|
||||
|
||||
private fun extractServerBase(url: String): String {
|
||||
val trimmed = url.trim()
|
||||
return when {
|
||||
trimmed.contains("/index.php") -> trimmed.substringBefore("/index.php")
|
||||
trimmed.contains("/login/v2") -> trimmed.substringBefore("/login/v2")
|
||||
else -> {
|
||||
val schemeEnd = trimmed.indexOf("://")
|
||||
if (schemeEnd == -1) return trimmed.trimEnd('/')
|
||||
val pathStart = trimmed.indexOf('/', schemeEnd + 3)
|
||||
if (pathStart == -1) trimmed else trimmed.substring(0, pathStart)
|
||||
}
|
||||
}.trimEnd('/')
|
||||
}
|
||||
|
||||
private fun normalizeBrowserFlowUrl(qrData: String): String? {
|
||||
val trimmed = qrData.trim()
|
||||
if (trimmed.startsWith("http://", ignoreCase = true) || trimmed.startsWith("https://", ignoreCase = true)) {
|
||||
return trimmed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private val STATE_TOKEN_REGEX = Regex("""name=["']stateToken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)
|
||||
private val REQUEST_TOKEN_REGEX = Regex("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)
|
||||
|
||||
private enum class QrPayload { DirectLogin, OneTimeLogin }
|
||||
|
||||
private fun extractPayload(qrData: String): QrPayload? = when {
|
||||
qrData.startsWith(F7_LOGIN_PREFIX) -> QrPayload.DirectLogin
|
||||
qrData.startsWith(F7_OTP_PREFIX) -> QrPayload.OneTimeLogin
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun extractParams(qrData: String): String {
|
||||
val prefix = when {
|
||||
qrData.startsWith(F7_LOGIN_PREFIX) -> F7_LOGIN_PREFIX
|
||||
qrData.startsWith(F7_OTP_PREFIX) -> F7_OTP_PREFIX
|
||||
else -> return qrData
|
||||
}
|
||||
return qrData.removePrefix(prefix)
|
||||
}
|
||||
|
||||
private fun parseDirectLogin(params: String): QrLoginResult? {
|
||||
val parsed = parseCredentialParams(params) ?: return null
|
||||
return QrLoginResult(parsed.server.trimEnd('/'), parsed.user, parsed.password)
|
||||
}
|
||||
|
||||
private fun parseOneTimeLogin(params: String, trustAllCerts: Boolean): QrLoginResult? {
|
||||
val parsed = parseCredentialParams(params) ?: return null
|
||||
val client = OkHttpClient.Builder().applyUnsafeSslIfNeeded(trustAllCerts).build()
|
||||
val credentials = Credentials.basic(parsed.user, parsed.password)
|
||||
val url = "${parsed.server.trimEnd('/')}/ocs/v2.php/core/getapppassword-onetime"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Authorization", credentials)
|
||||
.header("OCS-APIRequest", "true")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val body = response.body?.string().orEmpty()
|
||||
val appPassword = JSONObject(body)
|
||||
.optJSONObject("ocs")
|
||||
?.optJSONObject("data")
|
||||
?.optString("apppassword")
|
||||
.orEmpty()
|
||||
if (appPassword.isBlank()) return null
|
||||
return QrLoginResult(parsed.server.trimEnd('/'), parsed.user, appPassword)
|
||||
}
|
||||
}
|
||||
|
||||
private data class CredentialParams(
|
||||
val server: String,
|
||||
val user: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
private fun parseCredentialParams(params: String): CredentialParams? {
|
||||
val values = params.split('&')
|
||||
if (values.isEmpty() || values.size > 3) return null
|
||||
var server = ""
|
||||
var user = ""
|
||||
var password = ""
|
||||
values.forEach { value ->
|
||||
when {
|
||||
value.startsWith("user:") -> user = decode(value.removePrefix("user:"))
|
||||
value.startsWith("server:") -> server = decode(value.removePrefix("server:"))
|
||||
value.startsWith("password:") -> password = decode(value.removePrefix("password:"))
|
||||
}
|
||||
}
|
||||
if (server.isBlank() || user.isBlank() || password.isBlank()) return null
|
||||
return CredentialParams(server, user, password)
|
||||
}
|
||||
|
||||
suspend fun pollBrowserLogin(serverUrl: String, trustAllCerts: Boolean = false): QrLoginResult? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
val client = OkHttpClient.Builder().applyUnsafeSslIfNeeded(trustAllCerts).build()
|
||||
val startRequest = Request.Builder()
|
||||
.url("$base/index.php/login/v2")
|
||||
.post(FormBody.Builder().build())
|
||||
.header("Clear-Site-Data", "cookies")
|
||||
.build()
|
||||
val startBody = client.newCall(startRequest).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
response.body?.string().orEmpty()
|
||||
}
|
||||
val json = JSONObject(startBody)
|
||||
val poll = json.optJSONObject("poll") ?: return@withContext null
|
||||
val token = poll.optString("token")
|
||||
val pollUrl = poll.optString("endpoint")
|
||||
if (token.isBlank() || pollUrl.isBlank()) return@withContext null
|
||||
repeat(120) {
|
||||
val pollRequest = Request.Builder()
|
||||
.url(pollUrl)
|
||||
.post(FormBody.Builder().add("token", token).build())
|
||||
.build()
|
||||
val result = client.newCall(pollRequest).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) return@use null
|
||||
val obj = JSONObject(body)
|
||||
QrLoginResult(
|
||||
serverUrl = obj.optString("server", base).trimEnd('/'),
|
||||
username = obj.optString("loginName"),
|
||||
appPassword = obj.optString("appPassword"),
|
||||
).takeIf { it.username.isNotBlank() && it.appPassword.isNotBlank() }
|
||||
}
|
||||
if (result != null) return@withContext result
|
||||
delay(250)
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
private fun decode(value: String): String =
|
||||
runCatching { URLDecoder.decode(value, Charsets.UTF_8.name()) }.getOrDefault(value)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object NetworkFactory {
|
||||
fun newAuthedClient(
|
||||
username: String,
|
||||
appPassword: String,
|
||||
trustAllCerts: Boolean = false,
|
||||
callTimeoutSeconds: Long = 30,
|
||||
readTimeoutSeconds: Long = 30,
|
||||
): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.callTimeout(callTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.applyUnsafeSslIfNeeded(trustAllCerts)
|
||||
.addInterceptor(BasicAuthInterceptor(username, appPassword))
|
||||
.build()
|
||||
}
|
||||
|
||||
/** Collabora / richdocuments: cold start and WOPI can be slow on mobile networks. */
|
||||
fun newAuthedClientForOffice(
|
||||
username: String,
|
||||
appPassword: String,
|
||||
trustAllCerts: Boolean = false,
|
||||
): OkHttpClient = newAuthedClient(
|
||||
username = username,
|
||||
appPassword = appPassword,
|
||||
trustAllCerts = trustAllCerts,
|
||||
callTimeoutSeconds = 120,
|
||||
readTimeoutSeconds = 120,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
data class F7Notification(
|
||||
val id: Long,
|
||||
val subject: String,
|
||||
val message: String,
|
||||
val datetime: String,
|
||||
val link: String,
|
||||
val app: String,
|
||||
val icon: String = "",
|
||||
)
|
||||
|
||||
class NotificationsRepository {
|
||||
fun load(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
appPassword: String,
|
||||
trustAllCerts: Boolean = false,
|
||||
limit: Int = 50,
|
||||
): List<F7Notification> {
|
||||
val client = NetworkFactory.newAuthedClient(username, appPassword, trustAllCerts)
|
||||
val url = "${serverUrl.trimEnd('/')}/ocs/v2.php/apps/notifications/api/v2/notifications" +
|
||||
"?format=json&limit=$limit"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("OCS-APIRequest", "true")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Уведомления HTTP ${response.code}")
|
||||
}
|
||||
val ocs = JSONObject(response.body!!.string()).optJSONObject("ocs")
|
||||
?: error("Некорректный ответ уведомлений")
|
||||
val meta = ocs.optJSONObject("meta")
|
||||
if (meta?.optString("status").equals("failure", ignoreCase = true)) {
|
||||
error(meta?.optString("message").orEmpty().ifBlank { "Ошибка уведомлений" })
|
||||
}
|
||||
val data = ocs.opt("data")
|
||||
val array = when (data) {
|
||||
is JSONArray -> data
|
||||
is JSONObject -> JSONArray().put(data)
|
||||
else -> JSONArray()
|
||||
}
|
||||
return parseNotifications(array)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNotifications(array: JSONArray): List<F7Notification> {
|
||||
val out = mutableListOf<F7Notification>()
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.optJSONObject(i) ?: continue
|
||||
val id = obj.optLong("notification_id", 0L)
|
||||
if (id <= 0L) continue
|
||||
val subject = obj.optString("subject").ifBlank { obj.optString("app") }
|
||||
out += F7Notification(
|
||||
id = id,
|
||||
subject = subject,
|
||||
message = obj.optString("message"),
|
||||
datetime = obj.optString("datetime"),
|
||||
link = obj.optString("link"),
|
||||
app = obj.optString("app"),
|
||||
icon = obj.optString("icon"),
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun dismiss(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
appPassword: String,
|
||||
notificationId: Long,
|
||||
trustAllCerts: Boolean = false,
|
||||
) {
|
||||
val client = NetworkFactory.newAuthedClient(username, appPassword, trustAllCerts)
|
||||
val url = "${serverUrl.trimEnd('/')}/ocs/v2.php/apps/notifications/api/v2/notifications/$notificationId"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.delete()
|
||||
.header("OCS-APIRequest", "true")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful) {
|
||||
error("Уведомления HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
fun Request.Builder.applyOcsJson(): Request.Builder =
|
||||
header("OCS-APIRequest", "true")
|
||||
.header("Accept", "application/json")
|
||||
|
||||
fun parseJsonObject(body: String, what: String = "ответ сервера"): JSONObject {
|
||||
val trimmed = body.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
error("Пустой $what")
|
||||
}
|
||||
if (trimmed.startsWith("<?xml") || trimmed.startsWith("<")) {
|
||||
error("Сервер вернул XML вместо JSON ($what)")
|
||||
}
|
||||
return try {
|
||||
JSONObject(trimmed)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Некорректный JSON ($what)", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseJsonArray(body: String, what: String = "ответ сервера"): JSONArray {
|
||||
val trimmed = body.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
error("Пустой $what")
|
||||
}
|
||||
if (trimmed.startsWith("<?xml") || trimmed.startsWith("<")) {
|
||||
error("Сервер вернул XML вместо JSON ($what)")
|
||||
}
|
||||
return try {
|
||||
JSONArray(trimmed)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Некорректный JSON ($what)", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun JSONObject.ocsMeta(): JSONObject? = optJSONObject("ocs")?.optJSONObject("meta")
|
||||
|
||||
fun JSONObject.ocsData(): JSONObject? = optJSONObject("ocs")?.optJSONObject("data")
|
||||
|
||||
/** OCS v1 uses statuscode 100; OCS v2 uses meta.status "ok" or HTTP-style 200–299. */
|
||||
fun isOcsSuccess(meta: JSONObject?): Boolean {
|
||||
if (meta == null) return false
|
||||
if (meta.optString("status") == "ok") return true
|
||||
val code = meta.optInt("statuscode", 0)
|
||||
return code == 100 || code in 200..299
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
class UnauthorizedException(message: String = "Session expired") : RuntimeException(message)
|
||||
@@ -0,0 +1,25 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
internal fun OkHttpClient.Builder.applyUnsafeSslIfNeeded(trustAllCerts: Boolean): OkHttpClient.Builder {
|
||||
if (!trustAllCerts) return this
|
||||
val trustAll = arrayOf<TrustManager>(
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||
},
|
||||
)
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, trustAll, SecureRandom())
|
||||
val trustManager = trustAll[0] as X509TrustManager
|
||||
sslSocketFactory(sslContext.socketFactory, trustManager)
|
||||
hostnameVerifier { _, _ -> true }
|
||||
return this
|
||||
}
|
||||
Reference in New Issue
Block a user