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:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
@@ -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 200299. */
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
}