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
+25
View File
@@ -0,0 +1,25 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'ru.forbion.f7cloud.core.auth'
compileSdk 36
defaultConfig {
minSdk 26
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:network')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
}
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,102 @@
package ru.forbion.f7cloud.core.auth
import android.content.Context
import java.security.MessageDigest
class AppLockStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, false)
fun useBiometric(): Boolean = prefs.getBoolean(KEY_BIOMETRIC, false)
fun shouldOfferSetup(): Boolean =
!prefs.getBoolean(KEY_SETUP_OFFERED, false) && !isEnabled()
fun markSetupOffered() {
prefs.edit().putBoolean(KEY_SETUP_OFFERED, true).apply()
}
fun markBackgrounded(at: Long = System.currentTimeMillis()) {
prefs.edit().putLong(KEY_BACKGROUND_AT, at).apply()
}
fun clearBackgroundMarker() {
prefs.edit().remove(KEY_BACKGROUND_AT).apply()
}
/** Lock after the app stayed in background (process still alive) for at least [lockDelayMs]. */
fun shouldRequireUnlock(
now: Long = System.currentTimeMillis(),
lockDelayMs: Long = DEFAULT_LOCK_DELAY_MS,
): Boolean {
if (!isEnabled()) return false
val backgroundAt = prefs.getLong(KEY_BACKGROUND_AT, 0L)
if (backgroundAt <= 0L) return false
return now - backgroundAt >= lockDelayMs
}
/**
* True once per process start (app was killed / fully closed and opened again).
* Always requires lock even if background timeout has not elapsed.
*/
fun consumeColdStart(): Boolean {
if (!coldStartPending) return false
coldStartPending = false
return isEnabled()
}
fun enable(pin: String, biometric: Boolean) {
prefs.edit()
.putBoolean(KEY_ENABLED, true)
.putString(KEY_PIN_HASH, hashPin(pin))
.putBoolean(KEY_BIOMETRIC, biometric)
.putBoolean(KEY_BIOMETRIC_ONLY, false)
.apply()
}
fun enableBiometricOnly() {
prefs.edit()
.putBoolean(KEY_ENABLED, true)
.putBoolean(KEY_BIOMETRIC, true)
.putBoolean(KEY_BIOMETRIC_ONLY, true)
.remove(KEY_PIN_HASH)
.apply()
}
fun isBiometricOnly(): Boolean = prefs.getBoolean(KEY_BIOMETRIC_ONLY, false)
fun disable() {
prefs.edit()
.putBoolean(KEY_ENABLED, false)
.remove(KEY_PIN_HASH)
.putBoolean(KEY_BIOMETRIC, false)
.putBoolean(KEY_BIOMETRIC_ONLY, false)
.apply()
}
fun verifyPin(pin: String): Boolean {
val stored = prefs.getString(KEY_PIN_HASH, null) ?: return false
return stored == hashPin(pin)
}
private fun hashPin(pin: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val bytes = digest.digest(pin.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
companion object {
private const val PREFS = "f7_app_lock"
const val DEFAULT_LOCK_DELAY_MS = 60_000L
private const val KEY_ENABLED = "enabled"
private const val KEY_BACKGROUND_AT = "background_at"
private const val KEY_BIOMETRIC = "biometric"
private const val KEY_BIOMETRIC_ONLY = "biometric_only"
private const val KEY_PIN_HASH = "pin_hash"
private const val KEY_SETUP_OFFERED = "setup_offered"
@Volatile
private var coldStartPending = true
}
}
@@ -0,0 +1,55 @@
package ru.forbion.f7cloud.core.auth
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.security.cert.CertPathValidatorException
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLPeerUnverifiedException
fun Throwable.toLoginErrorMessage(): String {
val msg = message.orEmpty()
if (this is SSLPeerUnverifiedException ||
this is SSLHandshakeException ||
this is CertPathValidatorException ||
this is SSLException ||
msg.contains("not verified", ignoreCase = true) ||
msg.contains("CertificateException", ignoreCase = true) ||
msg.contains("Trust anchor", ignoreCase = true)
) {
return "Ошибка HTTPS: сертификат не совпадает с адресом сервера. " +
"Проверьте URL (https://ваш-домен). " +
"Для внутреннего dev-сервера без Let's Encrypt включите «Доверять сертификату»."
}
if (this is UnknownHostException) {
return "Сервер не найден. Проверьте адрес (например https://forbion.f7cloud.ru)."
}
if (this is ConnectException) {
return "Не удалось подключиться к серверу. Проверьте интернет и адрес."
}
if (this is SocketTimeoutException) {
return "Сервер не отвечает (таймаут)."
}
if (msg.contains("401", ignoreCase = true) ||
msg.contains("Unauthorised", ignoreCase = true) ||
msg.contains("997", ignoreCase = true)
) {
return "Неверный логин или пароль. " +
"Проверьте учётные данные. При включённой 2FA нужен пароль приложения из настроек безопасности."
}
if (msg.startsWith("Auth failed:")) {
return msg.removePrefix("Auth failed: ").ifBlank { "Ошибка авторизации" }
}
return msg.ifBlank { "Ошибка входа" }
}
fun normalizeServerUrl(raw: String): String {
val trimmed = raw.trim().trimEnd('/')
if (trimmed.isEmpty()) return trimmed
return when {
trimmed.startsWith("http://", ignoreCase = true) -> trimmed
trimmed.startsWith("https://", ignoreCase = true) -> trimmed
else -> "https://$trimmed"
}
}
@@ -0,0 +1,12 @@
package ru.forbion.f7cloud.core.auth
data class AuthSession(
val serverUrl: String,
val username: String,
/** Пароль учётной записи или пароль приложения (при 2FA). */
val appPassword: String,
/** ID для WebDAV (`/remote.php/dav/files/{id}/`), из OCS cloud/user. */
val davUserId: String? = null,
/** Только для тестовых серверов с самоподписанным сертификатом. */
val trustAllCerts: Boolean = false,
)
@@ -0,0 +1,34 @@
package ru.forbion.f7cloud.core.auth
import android.content.Context
class AuthStore(context: Context) {
private val prefs = context.getSharedPreferences("f7_auth", Context.MODE_PRIVATE)
fun save(session: AuthSession) {
prefs.edit()
.putString("server_url", session.serverUrl.trimEnd('/'))
.putString("username", session.username.trim())
.putString("app_password", session.appPassword)
.putBoolean("trust_all_certs", session.trustAllCerts)
.putString("dav_user_id", session.davUserId)
.apply()
}
fun load(): AuthSession? {
val serverUrl = prefs.getString("server_url", null) ?: return null
val username = prefs.getString("username", null) ?: return null
val appPassword = prefs.getString("app_password", null) ?: return null
return AuthSession(
serverUrl = serverUrl,
username = username,
appPassword = appPassword,
trustAllCerts = prefs.getBoolean("trust_all_certs", false),
davUserId = prefs.getString("dav_user_id", null),
)
}
fun clear() {
prefs.edit().clear().apply()
}
}
@@ -0,0 +1,47 @@
package ru.forbion.f7cloud.core.auth
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.applyOcsJson
import ru.forbion.f7cloud.core.network.isOcsSuccess
import ru.forbion.f7cloud.core.network.ocsData
import ru.forbion.f7cloud.core.network.ocsMeta
import ru.forbion.f7cloud.core.network.parseJsonObject
object AuthVerifier {
suspend fun verify(session: AuthSession): Result<AuthSession> {
return runCatching {
withContext(Dispatchers.IO) {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val request = Request.Builder()
.url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json")
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
error("Auth failed: HTTP ${response.code}")
}
val root = parseJsonObject(response.body!!.string(), "профиль пользователя")
val meta = root.ocsMeta()
if (!isOcsSuccess(meta)) {
val message = meta?.optString("message").orEmpty()
error("Auth failed: ${message.ifBlank { "HTTP ${response.code}" }}")
}
val userId = root.ocsData()?.optString("id").orEmpty().trim()
session.copy(
davUserId = userId.ifBlank { session.username },
)
}
}
}.fold(
onSuccess = { Result.success(it) },
onFailure = { Result.failure(Exception(it.toLoginErrorMessage(), it)) },
)
}
}
@@ -0,0 +1,40 @@
package ru.forbion.f7cloud.core.auth
import okhttp3.Request
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.applyOcsJson
import ru.forbion.f7cloud.core.network.isOcsSuccess
import ru.forbion.f7cloud.core.network.ocsData
import ru.forbion.f7cloud.core.network.ocsMeta
import ru.forbion.f7cloud.core.network.parseJsonObject
object OcsUserResolver {
fun resolveDavUserId(session: AuthSession): String {
session.davUserId?.let { return it }
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val request = Request.Builder()
.url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json")
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) {
throw UnauthorizedException()
}
if (!response.isSuccessful || response.body == null) {
error("User profile HTTP ${response.code}")
}
val root = parseJsonObject(response.body!!.string(), "профиль пользователя")
val meta = root.ocsMeta()
if (!isOcsSuccess(meta)) {
error("User profile OCS error")
}
val id = root.ocsData()?.optString("id").orEmpty().trim()
return id.ifBlank { session.username }
}
}
}
+26
View File
@@ -0,0 +1,26 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'ru.forbion.f7cloud.core.data'
compileSdk 36
defaultConfig {
minSdk 26
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:database')
implementation project(':core:network')
}
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
+27
View File
@@ -0,0 +1,27 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'com.google.devtools.ksp'
}
android {
namespace 'ru.forbion.f7cloud.core.database'
compileSdk 36
defaultConfig {
minSdk 26
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
api 'androidx.room:room-runtime:2.7.2'
implementation 'androidx.room:room-ktx:2.7.2'
ksp 'androidx.room:room-compiler:2.7.2'
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,27 @@
package ru.forbion.f7cloud.core.database
import androidx.room.Entity
import androidx.room.Index
@Entity(
tableName = "contacts",
primaryKeys = ["accountKey", "uid"],
indices = [Index("accountKey")],
)
data class ContactEntity(
val accountKey: String,
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 = "",
)
@@ -0,0 +1,46 @@
package ru.forbion.f7cloud.core.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface ContactsDao {
@Query(
"""
SELECT * FROM contacts
WHERE accountKey = :accountKey
ORDER BY displayName COLLATE NOCASE
""",
)
fun observeAll(accountKey: String): Flow<List<ContactEntity>>
@Query(
"""
SELECT * FROM contacts
WHERE accountKey = :accountKey
ORDER BY displayName COLLATE NOCASE
""",
)
suspend fun getAll(accountKey: String): List<ContactEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(contacts: List<ContactEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(contact: ContactEntity)
@Query("DELETE FROM contacts WHERE accountKey = :accountKey")
suspend fun deleteAll(accountKey: String)
@Transaction
suspend fun replaceAll(accountKey: String, contacts: List<ContactEntity>) {
deleteAll(accountKey)
if (contacts.isNotEmpty()) {
insertAll(contacts)
}
}
}
@@ -0,0 +1,80 @@
package ru.forbion.f7cloud.core.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [FileEntity::class, ContactEntity::class],
version = 4,
exportSchema = false,
)
abstract class F7Database : RoomDatabase() {
abstract fun filesDao(): FilesDao
abstract fun contactsDao(): ContactsDao
companion object {
@Volatile
private var INSTANCE: F7Database? = null
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS contacts (
accountKey TEXT NOT NULL,
uid TEXT NOT NULL,
displayName TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT NOT NULL,
bookName TEXT NOT NULL,
PRIMARY KEY(accountKey, uid)
)
""".trimIndent(),
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS index_contacts_accountKey ON contacts(accountKey)",
)
}
}
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE contacts ADD COLUMN photoBase64 TEXT NOT NULL DEFAULT ''",
)
db.execSQL(
"ALTER TABLE contacts ADD COLUMN photoMimeType TEXT NOT NULL DEFAULT ''",
)
}
}
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE contacts ADD COLUMN organization TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE contacts ADD COLUMN title TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE contacts ADD COLUMN address TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE contacts ADD COLUMN website TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE contacts ADD COLUMN birthday TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE contacts ADD COLUMN emails TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE contacts ADD COLUMN phones TEXT NOT NULL DEFAULT ''")
}
}
fun get(context: Context): F7Database {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
F7Database::class.java,
"f7cloud-mobile.db",
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.build()
.also { INSTANCE = it }
}
}
}
}
@@ -0,0 +1,14 @@
package ru.forbion.f7cloud.core.database
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "files")
data class FileEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val serverUrl: String,
val username: String,
val name: String,
val isDirectory: Boolean,
)
@@ -0,0 +1,18 @@
package ru.forbion.f7cloud.core.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface FilesDao {
@Query("SELECT * FROM files WHERE serverUrl = :serverUrl AND username = :username ORDER BY isDirectory DESC, name ASC")
suspend fun list(serverUrl: String, username: String): List<FileEntity>
@Query("DELETE FROM files WHERE serverUrl = :serverUrl AND username = :username")
suspend fun clear(serverUrl: String, username: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<FileEntity>)
}
+35
View File
@@ -0,0 +1,35 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
}
android {
namespace 'ru.forbion.f7cloud.core.designsystem'
compileSdk 36
defaultConfig {
minSdk 26
}
buildFeatures {
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.compose.material3:material3'
implementation 'io.coil-kt:coil-compose:2.6.0'
implementation 'io.coil-kt:coil-svg:2.6.0'
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,15 @@
package ru.forbion.f7cloud.core.designsystem
/**
* Updated from [F7MobileApp] via ProcessLifecycleOwner.
* Background polling loops should check this before hitting the network.
*/
object AppForegroundTracker {
@Volatile
var isForeground: Boolean = true
private set
fun setForeground(foreground: Boolean) {
isForeground = foreground
}
}
@@ -0,0 +1,222 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
private val BottomBarReserve: Dp = 90.dp
private val MenuIconSize = 62.dp
private val MenuGridGap = 20.dp
data class F7AppMenuItem(
val label: String,
val iconUrl: String,
val selected: Boolean,
val externalUrl: String? = null,
)
@Composable
fun F7AppMenuSheet(
visible: Boolean,
serverUrl: String,
items: List<F7AppMenuItem>,
onDismiss: () -> Unit,
onItemClick: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
var searchQuery by remember(visible) { mutableStateOf("") }
val filteredItems = remember(items, searchQuery) {
val query = searchQuery.trim()
if (query.isBlank()) {
items
} else {
items.filter { it.label.contains(query, ignoreCase = true) }
}
}
val base = serverUrl.trimEnd('/')
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250)) + slideInVertically(
animationSpec = tween(350),
initialOffsetY = { it },
),
exit = fadeOut(tween(200)) + slideOutVertically(
animationSpec = tween(300),
targetOffsetY = { it },
),
modifier = modifier,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = BottomBarReserve)
.navigationBarsPadding()
.background(F7Colors.Background)
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
) {
F7AppMenuSearchField(
serverUrl = base,
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
)
LazyVerticalGrid(
columns = GridCells.Fixed(4),
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
contentPadding = PaddingValues(horizontal = 2.dp),
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(
items = filteredItems,
key = { index, item -> "${item.label}-$index" },
) { index, item ->
val originalIndex = items.indexOf(item)
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter,
) {
F7AppMenuGridItem(
item = item,
onClick = {
if (originalIndex >= 0) {
onItemClick(originalIndex)
}
},
)
}
}
}
}
}
}
@Composable
private fun F7AppMenuSearchField(
serverUrl: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.height(40.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color.White)
.border(1.dp, Color(0xFFE6E6E6), RoundedCornerShape(20.dp)),
contentAlignment = Alignment.CenterStart,
) {
AsyncImage(
model = "$serverUrl/themes/forbion/images/header/search-glass.svg",
contentDescription = null,
modifier = Modifier
.padding(start = 10.dp)
.size(18.dp),
contentScale = ContentScale.Fit,
)
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = TextStyle(
fontSize = 14.sp,
color = F7Colors.TextPrimary,
),
cursorBrush = SolidColor(F7Colors.Primary),
modifier = Modifier
.fillMaxWidth()
.padding(start = 40.dp, end = 14.dp),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (value.isBlank()) {
Text(
text = "Поиск...",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF808080),
)
}
innerTextField()
}
},
)
}
}
@Composable
private fun F7AppMenuGridItem(
item: F7AppMenuItem,
onClick: () -> Unit,
) {
Column(
modifier = Modifier
.width(MenuIconSize)
.clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
AsyncImage(
model = item.iconUrl,
contentDescription = item.label,
modifier = Modifier.size(MenuIconSize),
contentScale = ContentScale.Fit,
)
Text(
text = item.label,
style = MaterialTheme.typography.labelLarge.copy(
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
lineHeight = 14.sp,
color = Color(0xFF151515),
),
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -0,0 +1,72 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
private val BottomBarFloatOffset = 6.dp
/**
* Bottom bar visibility — mirrors forbion [mobileBottomBarAutoHide] (4s idle hide).
*/
@Composable
fun F7AutoHideBottomBar(
enabled: Boolean,
pinned: Boolean,
activityNonce: Int,
modifier: Modifier = Modifier,
hideDelayMs: Long = 4000L,
content: @Composable () -> Unit,
) {
if (!enabled) return
var visible by remember { mutableStateOf(true) }
LaunchedEffect(enabled, pinned, activityNonce) {
if (pinned) {
visible = true
return@LaunchedEffect
}
visible = true
delay(hideDelayMs)
if (!pinned) {
visible = false
}
}
Box(
modifier = modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(bottom = BottomBarFloatOffset),
contentAlignment = Alignment.BottomCenter,
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + slideInVertically(initialOffsetY = { it / 2 }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 2 }),
modifier = Modifier.wrapContentWidth(),
) {
Box(contentAlignment = Alignment.BottomCenter) {
content()
}
}
}
}
@@ -0,0 +1,87 @@
package ru.forbion.f7cloud.core.designsystem
enum class F7BottomBarSlot {
Chats,
NavBack,
Create,
Profile,
Notifications,
Settings,
Menu,
}
data class F7BottomBarConfig(
val slots: List<F7BottomBarSlot>,
) {
val buttonCount: Int get() = slots.size
companion object {
fun forContext(
tabKey: String,
talkInRoom: Boolean,
): F7BottomBarConfig = when (tabKey) {
"Talk" -> if (talkInRoom) {
F7BottomBarConfig(listOf(F7BottomBarSlot.Profile, F7BottomBarSlot.Notifications, F7BottomBarSlot.Menu))
} else {
F7BottomBarConfig(
listOf(
F7BottomBarSlot.Chats,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
}
"Files" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.NavBack,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Create,
F7BottomBarSlot.Settings,
F7BottomBarSlot.Menu,
),
)
"Contacts" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.Create,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
"Tasks" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.Create,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
"Support" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.Create,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
"Mail", "Calendar" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.NavBack,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Settings,
F7BottomBarSlot.Menu,
),
)
else -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
}
}
}
@@ -0,0 +1,45 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.ui.graphics.Color
/**
* Palette from themes/forbion (mobile + f7support), light theme.
*/
object F7Colors {
val Primary = Color(0xFF70B62B)
val PrimaryHover = Color(0xFF6FAF2E)
val PrimaryDark = Color(0xFF5E922B)
val PrimaryLight = Color(0xFFECF9DE)
val PrimaryGradientStart = Color(0xFFC0FF7B)
val PrimaryGradientEnd = Color(0xFF7CBC3D)
val Background = Color(0xFFFBFBFB)
val Surface = Color(0xFFFFFFFF)
val SurfaceMuted = Color(0xFFF5F5F5)
val TextPrimary = Color(0xFF151515)
val TextSecondary = Color(0xFF808080)
val TextMuted = Color(0xFF8C8C8C)
val TextOnPrimary = Color(0xFFFFFFFF)
val Border = Color(0xFFE6E6E6)
val BorderLight = Color(0xFFE0E0E0)
val SecondaryButtonBg = Color(0xFFFDFDFD)
val SecondaryButtonBorder = Color(0xFFE6E6E6)
val Error = Color(0xFFD74642)
val ErrorBg = Color(0xFFFFE2E2)
val StatusNew = Color(0xFF2B9AB6)
val StatusProgress = Color(0xFF70B62B)
val StatusClosed = Color(0xFF808080)
val ChatBubbleIn = Color(0xFFFDFDFD)
val ChatBubbleOut = Color(0xFFE0F8C9)
val ChatBubbleSupport = Color(0xFFECF9DE)
val ChatText = Color(0xFF3F3F3F)
val ChatBackground = Color(0xFFE8EFE0)
val ChatDatePill = Color(0xFFF5F5F5)
val TalkComposerBorder = Color(0x3370B62B)
val TalkTopBarBorder = Color(0x3370B62B)
}
@@ -0,0 +1,585 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
/**
* Кнопки forbion (mobile):
* - [F7PrimaryButton] — градиент, CTA («Создать», «Войти», «Отправить»)
* - [F7SolidPrimaryButton] — сплошной зелёный, диалоги NC
* - [F7SecondaryButton] — outline, «Обновить», «Назад», «Отмена»
* - [F7TextButton] — tertiary, текст без фона
*/
@Composable
fun F7ScreenBackground(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier = modifier
.fillMaxSize()
.background(F7Colors.Background),
) {
content()
}
}
@Composable
fun F7ModuleScreen(
title: String? = null,
modifier: Modifier = Modifier,
loading: Boolean = false,
error: String? = null,
onRefresh: (() -> Unit)? = null,
headerActions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
val showTitle = !title.isNullOrBlank()
val showHeader = showTitle || onRefresh != null
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (showHeader) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (showTitle) {
Text(
text = title.orEmpty(),
style = MaterialTheme.typography.titleLarge,
color = F7Colors.TextPrimary,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
headerActions()
if (onRefresh != null) {
F7HeaderActionButton(
text = "",
onClick = onRefresh,
enabled = !loading,
)
}
}
}
}
if (loading) {
CircularProgressIndicator(color = F7Colors.Primary)
}
if (!error.isNullOrBlank()) {
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
content = content,
)
}
}
@Composable
fun F7PrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val shape = RoundedCornerShape(100.dp)
val gradient = Brush.linearGradient(
colors = listOf(F7Colors.PrimaryGradientStart, F7Colors.PrimaryGradientEnd),
)
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier
.heightIn(min = 44.dp)
.shadow(
elevation = 4.dp,
shape = shape,
spotColor = F7Colors.Primary.copy(alpha = 0.18f),
ambientColor = F7Colors.Primary.copy(alpha = 0.10f),
),
shape = shape,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
),
contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, F7Colors.Primary.copy(alpha = 0.22f), shape)
.background(brush = gradient, shape = shape)
.padding(horizontal = 18.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = F7Colors.TextOnPrimary,
style = MaterialTheme.typography.labelLarge,
)
}
}
}
@Composable
fun F7SolidPrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 40.dp),
shape = RoundedCornerShape(100.dp),
colors = ButtonDefaults.buttonColors(
containerColor = F7Colors.Primary,
contentColor = F7Colors.TextOnPrimary,
disabledContainerColor = F7Colors.Border,
disabledContentColor = F7Colors.TextSecondary,
),
) {
Text(text = text, style = MaterialTheme.typography.labelLarge)
}
}
@Composable
fun F7HeaderActionButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = modifier
.heightIn(min = 36.dp)
.widthIn(min = 36.dp, max = 52.dp),
shape = RoundedCornerShape(100.dp),
border = BorderStroke(1.dp, F7Colors.SecondaryButtonBorder),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = F7Colors.SecondaryButtonBg,
contentColor = F7Colors.TextPrimary,
),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
)
}
}
@Composable
fun F7SecondaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 40.dp),
shape = RoundedCornerShape(100.dp),
border = BorderStroke(1.dp, F7Colors.SecondaryButtonBorder),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = F7Colors.SecondaryButtonBg,
contentColor = F7Colors.TextPrimary,
),
) {
Text(text = text, style = MaterialTheme.typography.labelLarge)
}
}
@Composable
fun F7TextButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
TextButton(
onClick = onClick,
enabled = enabled,
modifier = modifier,
colors = ButtonDefaults.textButtonColors(
contentColor = F7Colors.TextPrimary,
disabledContentColor = F7Colors.TextSecondary,
),
) {
Text(text = text, style = MaterialTheme.typography.labelLarge)
}
}
@Composable
fun F7AlertDialog(
title: String,
onDismiss: () -> Unit,
confirmText: String,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
dismissText: String = "Отмена",
confirmEnabled: Boolean = true,
content: @Composable ColumnScope.() -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
modifier = modifier.widthIn(max = 400.dp),
shape = RoundedCornerShape(12.dp),
color = F7Colors.Surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(text = title, style = MaterialTheme.typography.titleMedium, color = F7Colors.TextPrimary)
content()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
F7SecondaryButton(text = dismissText, onClick = onDismiss)
F7SolidPrimaryButton(
text = confirmText,
onClick = onConfirm,
enabled = confirmEnabled,
)
}
}
}
}
}
@Composable
fun F7MessageComposer(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
modifier: Modifier = Modifier,
sending: Boolean = false,
label: String = "Сообщение",
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Bottom,
) {
F7OutlinedField(
value = value,
onValueChange = onValueChange,
label = label,
modifier = Modifier.weight(1f),
)
F7PrimaryButton(
text = if (sending) "" else "Отправить",
onClick = onSend,
enabled = value.isNotBlank() && !sending,
)
}
}
private val LINE_BREAK_CHARS = Regex("[\\r\\n\\u000B\\u000C\\u2028\\u2029\\u0085]")
private fun stripLineBreaks(text: String): String = text.replace(LINE_BREAK_CHARS, "")
private fun Modifier.consumeEnterKey(onEnter: () -> Unit): Modifier = this
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.isEnterKey()) {
onEnter()
true
} else {
false
}
}
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.isEnterKey()) {
onEnter()
true
} else {
false
}
}
private fun androidx.compose.ui.input.key.KeyEvent.isEnterKey(): Boolean =
key == Key.Enter || key == Key.NumPadEnter
@Composable
fun F7OutlinedField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
minLines: Int = 1,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
onEnter: (() -> Unit)? = null,
) {
val singleLine = minLines <= 1
val mergedKeyboardOptions = if (singleLine) {
keyboardOptions.copy(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
)
} else {
keyboardOptions
}
OutlinedTextField(
value = value,
onValueChange = { newValue ->
if (!singleLine) {
onValueChange(newValue)
return@OutlinedTextField
}
val hadLineBreak = LINE_BREAK_CHARS.containsMatchIn(newValue)
val stripped = stripLineBreaks(newValue)
if (stripped != value) {
onValueChange(stripped)
} else if (stripped != newValue) {
onValueChange(stripped)
}
if (hadLineBreak) {
onEnter?.invoke()
}
},
modifier = modifier
.fillMaxWidth()
.then(if (onEnter != null && singleLine) Modifier.consumeEnterKey(onEnter) else Modifier),
label = { Text(label) },
minLines = if (singleLine) 1 else minLines,
visualTransformation = visualTransformation,
keyboardOptions = mergedKeyboardOptions,
keyboardActions = keyboardActions,
singleLine = singleLine,
maxLines = if (singleLine) 1 else minLines,
shape = RoundedCornerShape(100.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = F7Colors.Primary,
unfocusedBorderColor = F7Colors.Border,
focusedContainerColor = Color(0xFFFDFDFD),
unfocusedContainerColor = Color(0xFFFDFDFD),
cursorColor = F7Colors.Primary,
),
)
}
@Composable
fun F7ListCard(
modifier: Modifier = Modifier,
selected: Boolean = false,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val shape = RoundedCornerShape(8.dp)
Column(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(F7Colors.Surface)
.border(
width = if (selected) 2.dp else 1.dp,
color = if (selected) F7Colors.PrimaryGradientEnd else F7Colors.Border,
shape = shape,
)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(12.dp),
content = content,
)
}
@Composable
fun F7ChatBubble(
text: String,
outgoing: Boolean,
subtitle: String? = null,
modifier: Modifier = Modifier,
) {
val bg = if (outgoing) F7Colors.ChatBubbleOut else F7Colors.ChatBubbleIn
val align = if (outgoing) Alignment.CenterEnd else Alignment.CenterStart
Box(modifier = modifier.fillMaxWidth(), contentAlignment = align) {
Column(
modifier = Modifier
.fillMaxWidth(0.88f)
.clip(RoundedCornerShape(8.dp))
.background(bg)
.border(1.dp, F7Colors.Border.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
if (!subtitle.isNullOrBlank()) {
Text(
text = subtitle,
style = MaterialTheme.typography.labelSmall,
color = F7Colors.TextSecondary,
)
}
Text(text = text, style = MaterialTheme.typography.bodyMedium, color = F7Colors.ChatText)
}
}
}
@Composable
fun F7TicketCard(
ticketNumber: String,
subject: String,
status: String,
preview: String,
hasUnread: Boolean,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val statusColor = when {
status.equals("Новый", ignoreCase = true) -> F7Colors.StatusNew
status.equals("В работе", ignoreCase = true) -> F7Colors.StatusProgress
else -> F7Colors.StatusClosed
}
F7ListCard(modifier = modifier, selected = selected, onClick = onClick) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(F7Colors.SurfaceMuted)
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = subject,
style = MaterialTheme.typography.titleSmall,
color = F7Colors.TextPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
F7StatusChip(text = status, color = statusColor)
}
Text(
text = "#$ticketNumber",
style = MaterialTheme.typography.labelSmall,
color = F7Colors.TextSecondary,
modifier = Modifier.padding(top = 6.dp),
)
if (preview.isNotBlank()) {
Text(
text = preview,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 8.dp),
)
}
if (hasUnread) {
Text(
text = "Новое",
color = F7Colors.Primary,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
@Composable
fun F7StatusChip(text: String, color: Color) {
Text(
text = text,
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(color)
.padding(horizontal = 12.dp, vertical = 4.dp),
color = F7Colors.TextOnPrimary,
style = MaterialTheme.typography.labelSmall,
)
}
@Composable
fun F7AppScaffold(
modifier: Modifier = Modifier,
bottomBar: @Composable () -> Unit = {},
content: @Composable (Modifier) -> Unit,
) {
Box(modifier = modifier.fillMaxSize()) {
F7ScreenBackground(modifier = Modifier.fillMaxSize()) {
content(
Modifier
.fillMaxSize()
.f7SafeTopInsets(),
)
}
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
contentAlignment = Alignment.BottomCenter,
) {
bottomBar()
}
}
}
@@ -0,0 +1,90 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Popup panel above the bottom bar — matches forbion mobile web
* (#header-menu-notifications, #header-menu-user-menu).
*/
@Composable
fun F7FloatingPanel(
visible: Boolean,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
bottomOffset: Dp = 80.dp,
fullHeight: Boolean = false,
contentPadding: PaddingValues = PaddingValues(16.dp),
content: @Composable ColumnScope.() -> Unit,
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + slideInVertically(initialOffsetY = { it / 4 }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 4 }),
) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = bottomOffset)
.background(Color.Black.copy(alpha = 0.18f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
)
Column(
modifier = modifier
.align(Alignment.BottomStart)
.padding(start = 16.dp, end = 12.dp, bottom = bottomOffset)
.then(if (fullHeight) Modifier.statusBarsPadding() else Modifier)
.navigationBarsPadding()
.fillMaxWidth()
.then(if (fullHeight) Modifier.fillMaxHeight() else Modifier)
.shadow(
elevation = 12.dp,
shape = RoundedCornerShape(16.dp),
spotColor = Color.Black.copy(alpha = 0.12f),
)
.clip(RoundedCornerShape(16.dp))
.background(F7Colors.SecondaryButtonBg)
.border(1.dp, F7Colors.Border, RoundedCornerShape(16.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
)
.padding(contentPadding),
content = content,
)
}
}
}
@@ -0,0 +1,188 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
data class F7BottomBarActions(
val onChatsClick: () -> Unit = {},
val onNavBackClick: () -> Unit = {},
val onCreateClick: () -> Unit = {},
val onProfileClick: () -> Unit = {},
val onNotificationsClick: () -> Unit = {},
val onSettingsClick: () -> Unit = {},
val onMenuClick: () -> Unit = {},
)
private val BottomBarButtonSize = 55.dp
private val BottomBarIconSize = 24.dp
private val BottomBarGap = 8.dp
private val BottomBarOuterPaddingH = 6.dp
private val BottomBarOuterPaddingV = 6.dp
private val BottomBarButtonShape = RoundedCornerShape(100.dp)
private val BottomBarBorderBrush = Brush.linearGradient(
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
)
private val BottomBarHighlightBrush = Brush.linearGradient(
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
)
@Composable
fun F7MobileBottomBar(
serverUrl: String,
userId: String,
config: F7BottomBarConfig,
actions: F7BottomBarActions,
menuOpen: Boolean = false,
chatsHighlighted: Boolean = false,
navBackHighlighted: Boolean = false,
showNotificationBadge: Boolean = false,
modifier: Modifier = Modifier,
) {
val base = serverUrl.trimEnd('/')
val pillShape = RoundedCornerShape(percent = 50)
Row(
modifier = modifier
.wrapContentWidth()
.shadow(
elevation = 2.dp,
shape = pillShape,
spotColor = Color(0xFFE6E6E6),
)
.clip(pillShape)
.background(Color(0xFFF5F5F5))
.padding(
horizontal = BottomBarOuterPaddingH,
vertical = BottomBarOuterPaddingV,
),
horizontalArrangement = Arrangement.spacedBy(BottomBarGap),
verticalAlignment = Alignment.CenterVertically,
) {
config.slots.forEach { slot ->
when (slot) {
F7BottomBarSlot.Chats -> F7BottomBarIconSlot(
iconUrl = if (chatsHighlighted) {
"$base/themes/forbion/images/header/chat-icon-green.svg"
} else {
"$base/themes/forbion/images/header/chat-icon-gray.svg"
},
contentDescription = "Чаты",
highlighted = chatsHighlighted,
onClick = actions.onChatsClick,
)
F7BottomBarSlot.NavBack -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/sidebar-chevron-left.svg",
contentDescription = "Папки",
highlighted = navBackHighlighted,
iconRotation = if (navBackHighlighted) 180f else 0f,
onClick = actions.onNavBackClick,
)
F7BottomBarSlot.Create -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/green-plus.svg",
contentDescription = "Создать",
onClick = actions.onCreateClick,
)
F7BottomBarSlot.Profile -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/profile-menu-icon-big.svg",
contentDescription = "Профиль",
onClick = actions.onProfileClick,
)
F7BottomBarSlot.Notifications -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/not-menu-icon-big.svg",
contentDescription = "Уведомления",
showBadge = showNotificationBadge,
onClick = actions.onNotificationsClick,
)
F7BottomBarSlot.Settings -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/setting-menu-icon.svg",
contentDescription = "Настройки",
onClick = actions.onSettingsClick,
)
F7BottomBarSlot.Menu -> F7BottomBarIconSlot(
iconUrl = if (menuOpen) {
"$base/themes/forbion/images/header/menu-burger-green.svg"
} else {
"$base/themes/forbion/images/header/menu-burger-gray.svg"
},
contentDescription = "Меню",
highlighted = menuOpen,
onClick = actions.onMenuClick,
)
}
}
}
}
@Composable
private fun F7BottomBarIconSlot(
iconUrl: String,
contentDescription: String,
onClick: () -> Unit,
highlighted: Boolean = false,
iconRotation: Float = 0f,
showBadge: Boolean = false,
) {
val bg = if (highlighted) BottomBarHighlightBrush else null
Box(
modifier = Modifier
.size(BottomBarButtonSize)
.clip(BottomBarButtonShape)
.then(
if (bg != null) {
Modifier.background(bg, BottomBarButtonShape)
} else {
Modifier.background(Color(0x99FFFFFF), BottomBarButtonShape)
},
)
.border(
width = 1.dp,
brush = BottomBarBorderBrush,
shape = BottomBarButtonShape,
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = iconUrl,
contentDescription = contentDescription,
modifier = Modifier
.size(BottomBarIconSize)
.graphicsLayer { rotationZ = iconRotation },
contentScale = ContentScale.Fit,
)
if (showBadge) {
Box(
Modifier
.align(Alignment.TopEnd)
.padding(top = 10.dp, end = 10.dp)
.size(8.dp)
.clip(BottomBarButtonShape)
.background(Color(0xFFE53935)),
)
}
}
}
@@ -0,0 +1,141 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import okhttp3.Credentials
private fun TextStyle.doubled(): TextStyle = copy(
fontSize = fontSize * 2,
lineHeight = lineHeight * 2,
)
@Composable
fun F7NotificationRow(
subject: String,
message: String,
relativeTime: String,
iconUrl: String?,
closeIconUrl: String,
authHeader: String?,
onClick: () -> Unit,
onDismiss: () -> Unit,
showDivider: Boolean,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
Column(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
if (relativeTime.isNotBlank()) {
Text(
text = relativeTime,
style = MaterialTheme.typography.labelSmall.doubled(),
fontWeight = FontWeight.Medium,
color = F7Colors.TextSecondary.copy(alpha = 0.55f),
)
}
Box(
modifier = Modifier
.padding(start = 8.dp)
.size(44.dp)
.clip(CircleShape)
.clickable(onClick = onDismiss),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = closeIconUrl,
contentDescription = "Закрыть",
modifier = Modifier.size(28.dp),
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top,
) {
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(F7Colors.SurfaceMuted),
contentAlignment = Alignment.Center,
) {
val model = if (!iconUrl.isNullOrBlank()) {
ImageRequest.Builder(context)
.data(iconUrl)
.apply {
authHeader?.let { addHeader("Authorization", it) }
}
.build()
} else {
null
}
if (model != null) {
AsyncImage(
model = model,
contentDescription = null,
modifier = Modifier.size(36.dp),
contentScale = ContentScale.Fit,
)
}
}
Text(
text = subject,
style = MaterialTheme.typography.bodyMedium.doubled(),
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
modifier = Modifier.weight(1f),
)
}
if (message.isNotBlank()) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall.doubled(),
fontWeight = FontWeight.Medium,
color = F7Colors.TextSecondary.copy(alpha = 0.7f),
maxLines = 6,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(start = 78.dp, top = 6.dp),
)
}
}
if (showDivider) {
HorizontalDivider(color = F7Colors.Border, thickness = 1.dp)
}
}
@@ -0,0 +1,108 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
interface F7OverlayNavigationScope {
fun registerDismissHandler(handler: () -> Boolean): () -> Unit
fun dismissTopOverlay(): Boolean
}
private class F7OverlayNavigationScopeImpl : F7OverlayNavigationScope {
private val handlers = mutableStateListOf<() -> Boolean>()
override fun registerDismissHandler(handler: () -> Boolean): () -> Unit {
handlers.add(handler)
return { handlers.remove(handler) }
}
override fun dismissTopOverlay(): Boolean {
for (index in handlers.indices.reversed()) {
if (handlers[index]()) {
return true
}
}
return false
}
}
val LocalF7OverlayNavigation = compositionLocalOf<F7OverlayNavigationScope?> { null }
@Composable
fun F7OverlayNavigationProvider(
onSwipeDismiss: () -> Boolean,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val scope = remember { F7OverlayNavigationScopeImpl() }
CompositionLocalProvider(LocalF7OverlayNavigation provides scope) {
androidx.compose.foundation.layout.Box(
modifier = modifier.f7SwipeFromRightToDismiss {
if (scope.dismissTopOverlay()) return@f7SwipeFromRightToDismiss
onSwipeDismiss()
},
) {
content()
}
}
}
@Composable
fun F7OverlayDismissHandler(
enabled: Boolean,
onDismiss: () -> Unit,
) {
val scope = LocalF7OverlayNavigation.current ?: return
DisposableEffect(enabled, scope, onDismiss) {
if (!enabled) {
return@DisposableEffect onDispose {}
}
val unregister = scope.registerDismissHandler {
onDismiss()
true
}
onDispose(unregister)
}
}
fun Modifier.f7SwipeFromRightToDismiss(
enabled: Boolean = true,
edgeFraction: Float = 0.24f,
dismissDistanceFraction: Float = 0.14f,
onDismiss: () -> Unit,
): Modifier {
if (!enabled) return this
return pointerInput(Unit) {
val edgeStartPx = size.width * (1f - edgeFraction)
val dismissDistancePx = size.width * dismissDistanceFraction
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
if (down.position.x < edgeStartPx) return@awaitEachGesture
var dragLeft = 0f
while (true) {
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) break
val delta = change.position.x - change.previousPosition.x
if (delta < 0f) {
dragLeft += -delta
}
if (dragLeft >= dismissDistancePx) {
onDismiss()
break
}
}
}
}
}
@@ -0,0 +1,34 @@
package ru.forbion.f7cloud.core.designsystem
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.util.Locale
fun formatNotificationRelativeTime(isoDatetime: String): String {
if (isoDatetime.isBlank()) return ""
val instant = runCatching { Instant.parse(isoDatetime) }.getOrNull() ?: return ""
val zone = ZoneId.systemDefault()
val date = instant.atZone(zone).toLocalDate()
val today = LocalDate.now(zone)
val days = ChronoUnit.DAYS.between(date, today)
return when {
days == 0L -> "сегодня"
days == 1L -> "вчера"
days == 2L -> "позавчера"
days in 3..6 -> "$days ${daysLabel(days)} назад"
else -> DateTimeFormatter.ofPattern("d MMM", Locale("ru")).format(date)
}
}
private fun daysLabel(days: Long): String {
val mod10 = (days % 10).toInt()
val mod100 = (days % 100).toInt()
return when {
mod10 == 1 && mod100 != 11 -> "день"
mod10 in 2..4 && mod100 !in 12..14 -> "дня"
else -> "дней"
}
}
@@ -0,0 +1,37 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val F7LightScheme = lightColorScheme(
primary = F7Colors.Primary,
onPrimary = F7Colors.TextOnPrimary,
primaryContainer = F7Colors.PrimaryLight,
onPrimaryContainer = F7Colors.TextPrimary,
secondary = F7Colors.PrimaryDark,
onSecondary = F7Colors.TextOnPrimary,
background = F7Colors.Background,
onBackground = F7Colors.TextPrimary,
surface = F7Colors.Surface,
onSurface = F7Colors.TextPrimary,
surfaceVariant = F7Colors.SurfaceMuted,
onSurfaceVariant = F7Colors.TextSecondary,
outline = F7Colors.Border,
error = F7Colors.Error,
onError = Color.White,
errorContainer = F7Colors.ErrorBg,
onErrorContainer = F7Colors.TextPrimary,
)
@Composable
fun F7Theme(
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = F7LightScheme,
typography = F7Typography,
content = content,
)
}
@@ -0,0 +1,27 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import ru.forbion.f7cloud.core.designsystem.R
val RalewayFamily = FontFamily(
Font(R.font.raleway_medium, FontWeight.Medium),
Font(R.font.raleway_semibold, FontWeight.SemiBold),
)
val F7Typography = Typography(
displayLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp),
titleLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp),
titleMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp),
titleSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
bodyLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp),
bodyMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
bodySmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp),
labelLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
labelMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp),
labelSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 14.sp),
)
@@ -0,0 +1,22 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/** Top + horizontal safe area (status bar, display cutout on foldables / punch-hole). */
@Composable
fun Modifier.f7SafeTopInsets(): Modifier = windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
)
/** Bottom navigation bar / gesture area when the app bottom bar is hidden. */
@Composable
fun Modifier.f7SafeBottomInsets(): Modifier = windowInsetsPadding(
WindowInsets.navigationBars,
)
+26
View File
@@ -0,0 +1,26 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'ru.forbion.f7cloud.core.network'
compileSdk 36
defaultConfig {
minSdk 26
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'org.json:json:20240303'
api 'com.squareup.okhttp3:okhttp:4.12.0'
}
@@ -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
}
+28
View File
@@ -0,0 +1,28 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'ru.forbion.f7cloud.core.push'
compileSdk 36
defaultConfig {
minSdk 26
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation 'com.google.firebase:firebase-messaging:24.0.1'
implementation 'androidx.core:core-ktx:1.15.0'
implementation 'androidx.core:core:1.15.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
}
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service
android:name=".F7FirebaseMessagingService"
android:directBootAware="true"
android:exported="false">
<intent-filter android:priority="1">
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<receiver
android:name=".F7CallActionReceiver"
android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,20 @@
package ru.forbion.f7cloud.core.push
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Handles Decline on incoming Talk call notifications. */
class F7CallActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_DECLINE) return
val roomToken = intent.getStringExtra(EXTRA_ROOM_TOKEN)
F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
}
companion object {
const val ACTION_DECLINE = "ru.forbion.f7cloud.action.DECLINE_CALL"
const val EXTRA_NOTIFICATION_ID = "notificationId"
const val EXTRA_ROOM_TOKEN = "roomToken"
}
}
@@ -0,0 +1,113 @@
package ru.forbion.f7cloud.core.push
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthStore
class F7FirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
Log.i(TAG, "FCM token refreshed")
val session = AuthStore(this).load()
if (session != null) {
CoroutineScope(Dispatchers.IO).launch {
val code = F7PushRegistrar.registerBlocking(this@F7FirebaseMessagingService, session, token)
Log.i(TAG, "push register result: $code")
}
}
super.onNewToken(token)
}
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
Log.i(TAG, "FCM data keys=${data.keys} priority=${message.priority}")
val type = data["type"]
val clickUrl = data["acceptUrl"]
?: data["url"]
?: data["clickUrl"]
?: data["link"]
val priority = data["priority"]
val highPriority = priority.equals("high", ignoreCase = true)
val title = message.notification?.title
?: data["title"]
?: "F7cloud"
val body = message.notification?.body
?: data["body"]
?: ""
// Only explicit call pushes should ring — Talk recording/chat links may also contain "/call/".
val isCall = type == "call"
val pushEvent = F7PushEventParser.parse(data, title, body)
F7PushEventHub.publish(pushEvent)
if (isCall) {
if (!canPostNotifications()) {
Log.w(TAG, "POST_NOTIFICATIONS denied — incoming call UI blocked")
}
val wakeLock = (getSystemService(POWER_SERVICE) as? PowerManager)
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "f7cloud:incoming_call")
?.apply { acquire(30_000L) }
try {
val shown = F7IncomingCallQueue.enqueue(
context = this,
title = title,
body = body,
acceptUrl = data["acceptUrl"] ?: clickUrl,
roomToken = data["roomToken"],
roomDisplayName = data["roomDisplayName"],
)
Log.i(TAG, "Incoming call enqueued: title=$title room=${data["roomToken"]} shown=$shown")
} catch (t: Throwable) {
Log.e(TAG, "Incoming call notification failed", t)
} finally {
wakeLock?.let { if (it.isHeld) it.release() }
}
return
}
try {
if (!canPostNotifications()) {
Log.w(TAG, "POST_NOTIFICATIONS denied — message notification skipped")
return
}
F7PushNotificationHelper.show(
context = this,
title = title,
body = body,
openUrl = clickUrl,
highPriority = highPriority,
type = type,
channelHint = data["channel"],
roomToken = data["roomToken"],
messageId = data["messageId"],
)
Log.i(TAG, "Message notification shown: $title")
} catch (t: Throwable) {
Log.e(TAG, "Message notification failed", t)
}
}
private fun canPostNotifications(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return true
}
return ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
}
companion object {
private const val TAG = "F7Push"
}
}
@@ -0,0 +1,448 @@
package ru.forbion.f7cloud.core.push
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthStore
/**
* Shows one incoming Talk call at a time; further calls wait in a FIFO queue.
*/
object F7IncomingCallQueue {
private const val TAG = "F7IncomingCallQueue"
private const val PREFS = "f7push_call_queue"
private const val KEY_QUEUE = "queue"
private const val KEY_ACTIVE_TOKEN = "active_token"
private const val KEY_ACTIVE_AT = "active_at"
const val ACTIVE_NOTIFICATION_ID = 5000
private const val ACTIVE_RING_TTL_MS = 3 * 60 * 1000L
private val lock = Any()
fun enqueue(
context: Context,
title: String,
body: String,
acceptUrl: String?,
roomToken: String?,
roomDisplayName: String? = null,
): Boolean {
val token = normalizeToken(roomToken, acceptUrl, title)
val displayName = TalkCallPushLabels.resolveRoomDisplayName(title, body, roomDisplayName)
synchronized(lock) {
val prefs = prefs(context)
expireStaleActiveLocked(prefs)
val call = PendingCall(token, title, body, acceptUrl, displayName)
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
if (token == active) {
// Duplicate FCM for the same room — refresh UI only, do not re-ring.
val shown = showNotification(context, call, waiting = 0, alert = false)
if (shown) {
touchActive(prefs)
}
return shown
}
val queue = readQueue(prefs)
if (containsToken(queue, token)) {
return false
}
if (active.isEmpty()) {
setActive(prefs, token)
val shown = showNotification(context, call, waiting = 0)
if (!shown) {
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
}
return shown
}
queue.put(call.toJson())
prefs.edit().putString(KEY_QUEUE, queue.toString()).apply()
Log.d(TAG, "Call queued: $token, queue size=${queue.length()}")
return false
}
}
fun dismissAndShowNext(context: Context, roomToken: String?) {
synchronized(lock) {
val prefs = prefs(context)
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
var queue = readQueue(prefs)
if (!roomToken.isNullOrBlank()) {
val token = roomToken.trim()
if (token != active) {
queue = removeToken(queue, token)
prefs.edit().putString(KEY_QUEUE, queue.toString()).apply()
return
}
}
cancelNotification(context)
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
if (queue.length() == 0) {
prefs.edit().remove(KEY_QUEUE).apply()
return
}
runCatching {
val next = PendingCall.fromJson(queue.getJSONObject(0))
val rest = JSONArray()
for (i in 1 until queue.length()) {
rest.put(queue.get(i))
}
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
if (showNotification(context, next, rest.length())) {
setActive(prefs, next.roomToken)
}
}.onFailure {
Log.w(TAG, "Failed to parse queued call", it)
prefs.edit().remove(KEY_QUEUE).apply()
}
}
}
fun clearAll(context: Context) {
synchronized(lock) {
val prefs = prefs(context)
cancelNotification(context)
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).apply()
}
}
private fun showNotification(
context: Context,
call: PendingCall,
waiting: Int,
alert: Boolean = true,
): Boolean {
F7NotificationChannels.ensureAll(context)
val body = if (waiting > 0) {
call.body + "\n" + context.resources.getQuantityString(
R.plurals.call_queue_waiting,
waiting,
waiting,
)
} else {
call.body
}
val joinUrl = resolveJoinUrl(context, call) ?: run {
Log.w(TAG, "Cannot resolve join URL for call ${call.roomToken}")
return false
}
val intents = buildPendingIntents(context, call, joinUrl)
if (alert) {
runCatching { F7IncomingCallRinger.start(context, call.roomToken) }
.onFailure { Log.w(TAG, "Ringtone start failed", it) }
}
val posted = runCatching {
postCallStyleNotification(context, call, body, intents, alert)
}.onFailure {
Log.w(TAG, "CallStyle notification failed, using fallback", it)
}.isSuccess || runCatching {
postFallbackNotification(context, call, body, intents, alert)
}.onFailure {
Log.e(TAG, "Fallback call notification failed", it)
}.isSuccess
if (!posted) {
F7IncomingCallRinger.stop()
}
return posted
}
private data class CallPendingIntents(
val accept: PendingIntent,
val preview: PendingIntent,
val decline: PendingIntent,
val fullScreen: PendingIntent,
)
private fun buildPendingIntents(
context: Context,
call: PendingCall,
joinUrl: String,
): CallPendingIntents {
val requestCode = ACTIVE_NOTIFICATION_ID + kotlin.math.abs(call.roomToken.hashCode() % 10000)
val accept = PendingIntent.getActivity(
context,
requestCode,
incomingCallIntent(context, call, joinUrl, autoAccept = true),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val preview = PendingIntent.getActivity(
context,
requestCode + 50000,
incomingCallIntent(context, call, joinUrl, autoAccept = false),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val declineIntent = Intent(context, F7CallActionReceiver::class.java).apply {
action = F7CallActionReceiver.ACTION_DECLINE
putExtra(F7CallActionReceiver.EXTRA_NOTIFICATION_ID, ACTIVE_NOTIFICATION_ID)
putExtra(F7CallActionReceiver.EXTRA_ROOM_TOKEN, call.roomToken)
}
val decline = PendingIntent.getBroadcast(
context,
ACTIVE_NOTIFICATION_ID + 1,
declineIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val fullScreen = PendingIntent.getActivity(
context,
requestCode + 60000,
incomingCallIntent(context, call, joinUrl, autoAccept = false),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return CallPendingIntents(accept, preview, decline, fullScreen)
}
private fun postCallStyleNotification(
context: Context,
call: PendingCall,
body: String,
intents: CallPendingIntents,
alert: Boolean,
) {
val caller = Person.Builder()
.setName(call.displayName.ifBlank { call.title.ifBlank { context.getString(R.string.incoming_call_subtitle) } })
.build()
val canFullScreen = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
context.getSystemService(NotificationManager::class.java)
?.canUseFullScreenIntent() != false
} else {
true
}
val callStyle = NotificationCompat.CallStyle.forIncomingCall(
caller,
intents.decline,
intents.accept,
)
val notification = NotificationCompat.Builder(context, F7NotificationChannels.CALLS)
.setSmallIcon(android.R.drawable.stat_sys_phone_call)
.setContentTitle(call.displayName.ifBlank { call.title })
.setContentText(body)
.setStyle(callStyle)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setSound(null)
.setDefaults(0)
.setVibrate(null)
.setContentIntent(intents.preview)
.apply {
if (canFullScreen) {
setFullScreenIntent(intents.fullScreen, true)
}
}
.build()
context.getSystemService(NotificationManager::class.java)
?.notify(ACTIVE_NOTIFICATION_ID, notification)
?: throw IllegalStateException("NotificationManager unavailable")
}
private fun postFallbackNotification(
context: Context,
call: PendingCall,
body: String,
intents: CallPendingIntents,
alert: Boolean,
) {
val canFullScreen = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
context.getSystemService(NotificationManager::class.java)
?.canUseFullScreenIntent() != false
} else {
true
}
val notification = NotificationCompat.Builder(context, F7NotificationChannels.CALLS)
.setSmallIcon(android.R.drawable.stat_sys_phone_call)
.setContentTitle(call.displayName.ifBlank { call.title })
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setSound(null)
.setDefaults(0)
.setVibrate(null)
.setContentIntent(intents.preview)
.apply {
if (canFullScreen) {
setFullScreenIntent(intents.fullScreen, true)
}
}
.addAction(0, context.getString(R.string.call_action_accept), intents.accept)
.addAction(0, context.getString(R.string.call_action_decline), intents.decline)
.build()
context.getSystemService(NotificationManager::class.java)
?.notify(ACTIVE_NOTIFICATION_ID, notification)
?: throw IllegalStateException("NotificationManager unavailable")
}
private fun resolveJoinUrl(context: Context, call: PendingCall): String? {
val raw = call.acceptUrl?.takeIf { it.isNotBlank() }
?: AuthStore(context).load()?.let { session ->
buildCallUrl(session.serverUrl, call.roomToken)
}
?: return null
return stripDirectCallHash(raw)
}
private fun buildCallUrl(serverBase: String, roomToken: String): String {
val base = serverBase.trimEnd('/')
return "$base/call/${roomToken.trim()}"
}
private fun stripDirectCallHash(url: String): String {
val hash = url.indexOf('#')
return if (hash >= 0) url.substring(0, hash) else url
}
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun setActive(prefs: android.content.SharedPreferences, token: String) {
prefs.edit()
.putString(KEY_ACTIVE_TOKEN, token)
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
.apply()
}
private fun touchActive(prefs: android.content.SharedPreferences) {
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
}
private fun expireStaleActiveLocked(prefs: android.content.SharedPreferences) {
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
if (active.isEmpty()) return
val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L)
if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) {
Log.w(TAG, "Clearing stale active call: $active")
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
}
}
private fun normalizeToken(roomToken: String?, acceptUrl: String?, fallback: String): String {
extractTokenFromUrl(acceptUrl)?.let { return it }
roomToken?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
return "call:${kotlin.math.abs(fallback.hashCode())}"
}
private fun extractTokenFromUrl(url: String?): String? {
if (url.isNullOrBlank()) return null
val path = runCatching { android.net.Uri.parse(url).path }.getOrNull() ?: url
val marker = "/call/"
val idx = path.indexOf(marker)
if (idx < 0) return null
val rest = path.substring(idx + marker.length)
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it }
return rest.substring(0, end).ifBlank { null }
}
private fun cancelNotification(context: Context) {
F7IncomingCallRinger.stop()
context.getSystemService(NotificationManager::class.java)
?.cancel(ACTIVE_NOTIFICATION_ID)
}
private fun readQueue(prefs: android.content.SharedPreferences): JSONArray {
val raw = prefs.getString(KEY_QUEUE, "[]").orEmpty()
return runCatching { JSONArray(raw) }.getOrDefault(JSONArray())
}
private fun containsToken(queue: JSONArray, token: String): Boolean {
for (i in 0 until queue.length()) {
runCatching {
if (token == queue.getJSONObject(i).optString("roomToken")) return true
}
}
return false
}
private fun removeToken(queue: JSONArray, token: String): JSONArray {
val next = JSONArray()
for (i in 0 until queue.length()) {
runCatching {
val item = queue.getJSONObject(i)
if (token != item.optString("roomToken")) {
next.put(item)
}
}
}
return next
}
private data class PendingCall(
val roomToken: String,
val title: String,
val body: String,
val acceptUrl: String?,
val displayName: String,
) {
fun toJson(): JSONObject = JSONObject().apply {
put("roomToken", roomToken)
put("title", title)
put("body", body)
put("displayName", displayName)
if (acceptUrl != null) put("acceptUrl", acceptUrl)
}
companion object {
fun fromJson(o: JSONObject): PendingCall = PendingCall(
roomToken = o.getString("roomToken"),
title = o.optString("title", ""),
body = o.optString("body", ""),
acceptUrl = if (o.has("acceptUrl")) o.optString("acceptUrl") else null,
displayName = o.optString("displayName", ""),
)
}
}
private const val INCOMING_CALL_ACTIVITY = "ru.forbion.f7cloud.mobile.CallIncomingActivity"
private fun incomingCallIntent(
context: Context,
call: PendingCall,
joinUrl: String,
autoAccept: Boolean,
): Intent = Intent().apply {
setClassName(context, INCOMING_CALL_ACTIVITY)
action = PushIntents.ACTION_OPEN_CALL
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP,
)
putExtra(PushIntents.EXTRA_ACCEPT_URL, joinUrl)
putExtra(PushIntents.EXTRA_ROOM_TOKEN, call.roomToken)
putExtra(PushIntents.EXTRA_CALL_TITLE, call.title)
putExtra(PushIntents.EXTRA_CALL_BODY, call.body)
putExtra(PushIntents.EXTRA_ROOM_DISPLAY_NAME, call.displayName)
putExtra(PushIntents.EXTRA_AUTO_ACCEPT, autoAccept)
}
}
@@ -0,0 +1,119 @@
package ru.forbion.f7cloud.core.push
import android.content.Context
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.util.Log
/**
* Loops the default ringtone while an incoming call waits for Accept/Decline.
* Debounced per call token so duplicate FCM / notification updates do not restart audio.
*/
object F7IncomingCallRinger {
private const val TAG = "F7IncomingCallRinger"
private const val RING_PREFS = "f7_call_ring_guard"
private const val KEY_LAST_TOKEN = "last_token"
private const val KEY_LAST_AT = "last_at"
private const val RING_DEBOUNCE_MS = 90_000L
private val lock = Any()
private var player: MediaPlayer? = null
private var ringing = false
private var activeToken: String? = null
fun isPlaying(): Boolean = synchronized(lock) {
runCatching { player?.isPlaying == true }.getOrDefault(false)
}
fun shouldAlert(context: Context, callToken: String): Boolean {
if (callToken.isBlank()) return false
synchronized(lock) {
if (runCatching { player?.isPlaying == true }.getOrDefault(false)) {
return false
}
val prefs = context.applicationContext.getSharedPreferences(RING_PREFS, Context.MODE_PRIVATE)
val now = System.currentTimeMillis()
val lastToken = prefs.getString(KEY_LAST_TOKEN, "").orEmpty()
val lastAt = prefs.getLong(KEY_LAST_AT, 0L)
if (callToken == lastToken && now - lastAt < RING_DEBOUNCE_MS) {
return false
}
prefs.edit()
.putString(KEY_LAST_TOKEN, callToken)
.putLong(KEY_LAST_AT, now)
.apply()
return true
}
}
fun start(context: Context, callToken: String) {
if (!shouldAlert(context, callToken)) {
return
}
synchronized(lock) {
ringing = true
activeToken = callToken
stopLocked(keepFlag = true)
val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
if (uri == null) {
Log.w(TAG, "No default ringtone URI")
stopLocked()
return
}
val appContext = context.applicationContext
runCatching {
player = MediaPlayer().apply {
setDataSource(appContext, uri)
isLooping = true
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
setOnPreparedListener { prepared ->
synchronized(lock) {
if (!ringing) {
runCatching { prepared.release() }
return@setOnPreparedListener
}
runCatching { prepared.start() }
.onFailure {
Log.w(TAG, "Ringtone start failed", it)
stopLocked()
}
}
}
setOnErrorListener { _, what, extra ->
Log.w(TAG, "Ringtone error what=$what extra=$extra")
synchronized(lock) { stopLocked() }
true
}
prepareAsync()
}
}.onFailure {
Log.w(TAG, "Ringtone prepare failed", it)
stopLocked()
}
}
}
fun stop() {
synchronized(lock) {
stopLocked()
}
}
private fun stopLocked(keepFlag: Boolean = false) {
if (!keepFlag) {
ringing = false
activeToken = null
}
player?.runCatching {
if (isPlaying) stop()
release()
}
player = null
}
}
@@ -0,0 +1,72 @@
package ru.forbion.f7cloud.core.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.media.AudioAttributes
import android.media.RingtoneManager
import android.os.Build
object F7NotificationChannels {
const val MESSAGES = "f7_mobile_messages"
/** Silent channel: ringtone is played only by [F7IncomingCallRinger]. */
const val CALLS = "f7_mobile_calls_v3"
/** Channel IDs referenced in FCM payloads from f7push server (background tray). */
private const val SERVER_MESSAGES = "f7cloud_messages_v2"
private const val SERVER_CALLS = "f7cloud_calls_v2"
fun ensureAll(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java) ?: return
val audio = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val notificationSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val ringtoneAudio = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val ringtone = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
fun create(
id: String,
name: String,
importance: Int,
vibration: LongArray,
sound: android.net.Uri?,
soundAttrs: AudioAttributes,
) {
manager.createNotificationChannel(
NotificationChannel(id, name, importance).apply {
description = name
enableLights(true)
enableVibration(true)
vibrationPattern = vibration
if (sound != null) {
setSound(sound, soundAttrs)
}
setShowBadge(true)
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
},
)
}
val messagesName = context.getString(R.string.notification_channel_messages)
val callsName = context.getString(R.string.notification_channel_calls)
val msgVibration = longArrayOf(0, 250, 150, 250)
val callVibration = longArrayOf(0, 500, 200, 500)
create(MESSAGES, messagesName, NotificationManager.IMPORTANCE_HIGH, msgVibration, notificationSound, audio)
create(SERVER_MESSAGES, messagesName, NotificationManager.IMPORTANCE_HIGH, msgVibration, notificationSound, audio)
create(CALLS, callsName, NotificationManager.IMPORTANCE_HIGH, longArrayOf(0), null, audio)
create(SERVER_CALLS, callsName, NotificationManager.IMPORTANCE_HIGH, longArrayOf(0), null, audio)
}
fun resolveChannel(channelHint: String?, highPriority: Boolean): String {
if (channelHint == CALLS || channelHint == SERVER_CALLS) return CALLS
if (channelHint == MESSAGES || channelHint == SERVER_MESSAGES) return MESSAGES
return if (highPriority) CALLS else MESSAGES
}
}
@@ -0,0 +1,43 @@
package ru.forbion.f7cloud.core.push
sealed class F7PushEvent {
abstract val url: String?
data class Call(
val title: String,
val body: String,
val roomToken: String?,
val acceptUrl: String?,
override val url: String?,
) : F7PushEvent()
data class Mail(
val title: String,
val body: String,
val mailboxId: Int?,
val messageId: Int?,
override val url: String?,
) : F7PushEvent()
data class Talk(
val title: String,
val body: String,
val roomToken: String?,
val messageId: Long?,
override val url: String?,
) : F7PushEvent()
data class Files(
val title: String,
val body: String,
val fileId: Long?,
override val url: String?,
) : F7PushEvent()
data class Notification(
val title: String,
val body: String,
val source: String?,
override val url: String?,
) : F7PushEvent()
}
@@ -0,0 +1,17 @@
package ru.forbion.f7cloud.core.push
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* In-app bridge for FCM payloads — lets open screens refresh without waiting for user action.
*/
object F7PushEventHub {
private val _events = MutableSharedFlow<F7PushEvent>(extraBufferCapacity = 32)
val events: SharedFlow<F7PushEvent> = _events.asSharedFlow()
fun publish(event: F7PushEvent) {
_events.tryEmit(event)
}
}
@@ -0,0 +1,82 @@
package ru.forbion.f7cloud.core.push
object F7PushEventParser {
fun parse(
data: Map<String, String>,
title: String,
body: String,
): F7PushEvent {
val type = data["type"]?.lowercase()
val source = data["source"]?.lowercase()
val url = data["acceptUrl"]
?: data["url"]
?: data["clickUrl"]
?: data["link"]
val lowerUrl = url?.lowercase().orEmpty()
if (type == "call") {
return F7PushEvent.Call(
title = title,
body = body,
roomToken = data["roomToken"],
acceptUrl = data["acceptUrl"] ?: url,
url = url,
)
}
if (type == "mail" || source == "mail" ||
lowerUrl.contains("/apps/f7mail") || lowerUrl.contains("/apps/mail")
) {
return F7PushEvent.Mail(
title = title,
body = body,
mailboxId = data["mailboxId"]?.toIntOrNull()
?: Regex("""/box/(\d+)""", RegexOption.IGNORE_CASE).find(lowerUrl)
?.groupValues?.get(1)?.toIntOrNull(),
messageId = data["messageId"]?.toIntOrNull()
?: Regex("""/thread/(\d+)""", RegexOption.IGNORE_CASE).find(lowerUrl)
?.groupValues?.get(1)?.toIntOrNull(),
url = url,
)
}
if (type == "chat" || source == "spreed" || lowerUrl.contains("/apps/spreed")) {
val roomToken = data["roomToken"]
?: extractTalkRoomToken(url)
val messageId = data["messageId"]?.toLongOrNull()
?: Regex("""#message_(\d+)""").find(url.orEmpty())?.groupValues?.get(1)?.toLongOrNull()
return F7PushEvent.Talk(
title = title,
body = body,
roomToken = roomToken,
messageId = messageId,
url = url,
)
}
if (source == "files" || lowerUrl.contains("/apps/files") || lowerUrl.contains("/remote.php/dav/files")) {
return F7PushEvent.Files(
title = title,
body = body,
fileId = data["fileId"]?.toLongOrNull()
?: Regex("""fileid=(\d+)""", RegexOption.IGNORE_CASE).find(lowerUrl)
?.groupValues?.get(1)?.toLongOrNull(),
url = url,
)
}
return F7PushEvent.Notification(
title = title,
body = body,
source = source ?: data["source"],
url = url,
)
}
private fun extractTalkRoomToken(url: String?): String? {
if (url.isNullOrBlank()) return null
Regex("""/spreed/([a-z0-9]+)""", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.let { return it }
Regex("""/call/([a-z0-9]+)""", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.let { return it }
return null
}
}
@@ -0,0 +1,49 @@
package ru.forbion.f7cloud.core.push
import android.content.Intent
import android.os.Bundle
object F7PushIntentExtras {
fun dataMap(extras: Bundle?): Map<String, String> {
if (extras == null) return emptyMap()
return buildMap {
for (key in extras.keySet()) {
if (key.startsWith("google.") || key == "from" || key == "collapse_key") continue
extras.getString(key)?.takeIf { it.isNotBlank() }?.let { put(key, it) }
}
}
}
fun resolveOpenUrl(intent: Intent?): String? {
if (intent == null) return null
intent.getStringExtra(PushIntents.EXTRA_OPEN_URL)?.takeIf { it.isNotBlank() }?.let { return it }
val data = dataMap(intent.extras)
return data["acceptUrl"]
?: data["url"]
?: data["clickUrl"]
?: data["link"]
}
fun resolveRoomToken(intent: Intent?): String? {
intent?.getStringExtra(PushIntents.EXTRA_ROOM_TOKEN)?.takeIf { it.isNotBlank() }?.let { return it }
return dataMap(intent?.extras)["roomToken"]
}
fun resolveMessageId(intent: Intent?): String? {
intent?.getStringExtra(PushIntents.EXTRA_MESSAGE_ID)?.takeIf { it.isNotBlank() }?.let { return it }
return dataMap(intent?.extras)["messageId"]
}
fun publishEventFromIntent(intent: Intent?) {
val data = dataMap(intent?.extras)
if (data.isEmpty()) return
val title = data["title"] ?: "F7cloud"
val body = data["body"] ?: ""
F7PushEventHub.publish(F7PushEventParser.parse(data, title, body))
}
fun isFcmLaunch(intent: Intent?): Boolean {
val extras = intent?.extras ?: return false
return extras.containsKey("google.message_id") || dataMap(extras).isNotEmpty()
}
}
@@ -0,0 +1,63 @@
package ru.forbion.f7cloud.core.push
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
object F7PushNotificationHelper {
fun show(
context: Context,
title: String,
body: String,
openUrl: String?,
highPriority: Boolean,
type: String?,
channelHint: String? = null,
roomToken: String? = null,
messageId: String? = null,
) {
F7NotificationChannels.ensureAll(context)
val manager = context.getSystemService(NotificationManager::class.java) ?: return
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
val pending = if (launch != null) {
val intent = Intent(launch)
if (!openUrl.isNullOrBlank()) {
intent.putExtra(PushIntents.EXTRA_OPEN_URL, openUrl)
}
if (!roomToken.isNullOrBlank()) {
intent.putExtra(PushIntents.EXTRA_ROOM_TOKEN, roomToken)
}
if (!messageId.isNullOrBlank()) {
intent.putExtra(PushIntents.EXTRA_MESSAGE_ID, messageId)
}
PendingIntent.getActivity(
context,
1001,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
} else {
null
}
val channel = F7NotificationChannels.resolveChannel(channelHint, highPriority || type == "call")
val priority = if (channel == F7NotificationChannels.CALLS) {
NotificationCompat.PRIORITY_HIGH
} else {
NotificationCompat.PRIORITY_DEFAULT
}
val iconRes = context.applicationInfo.icon.takeIf { it != 0 }
?: android.R.drawable.stat_notify_chat
val notification = NotificationCompat.Builder(context, channel)
.setSmallIcon(iconRes)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setPriority(priority)
.setContentIntent(pending)
.build()
manager.notify((System.currentTimeMillis() % Int.MAX_VALUE).toInt(), notification)
}
}
@@ -0,0 +1,90 @@
package ru.forbion.f7cloud.core.push
import android.content.Context
import android.util.Log
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import java.io.BufferedReader
import java.io.InputStream
import java.io.InputStreamReader
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
import java.util.UUID
object F7PushRegistrar {
private const val TAG = "F7PushRegistrar"
private const val PREFS = "f7push"
private const val KEY_DEVICE_ID = "device_id"
fun getDeviceId(context: Context): String {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val existing = prefs.getString(KEY_DEVICE_ID, null)
if (!existing.isNullOrBlank()) {
return existing
}
val id = UUID.randomUUID().toString()
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
return id
}
fun registerBlocking(
context: Context,
session: AuthSession,
fcmToken: String,
): Int {
val endpoint = session.serverUrl.trimEnd('/') + "/ocs/v2.php/apps/f7push/api/v1/devices"
val body = JSONObject()
.put("deviceId", getDeviceId(context))
.put("fcmToken", fcmToken)
.put("platform", "android")
.put("clientApp", "f7cloud-mobile")
.toString()
return try {
postJson(endpoint, session, body)
} catch (t: Throwable) {
Log.w(TAG, "register failed", t)
0
}
}
private fun postJson(endpoint: String, session: AuthSession, json: String): Int {
val conn = URL(endpoint).openConnection() as HttpURLConnection
conn.connectTimeout = 15000
conn.readTimeout = 15000
conn.requestMethod = "POST"
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
conn.setRequestProperty("Accept", "application/json")
conn.setRequestProperty("OCS-APIRequest", "true")
val basic = android.util.Base64.encodeToString(
"${session.username}:${session.appPassword}".toByteArray(StandardCharsets.UTF_8),
android.util.Base64.NO_WRAP
)
conn.setRequestProperty("Authorization", "Basic $basic")
val payload = json.toByteArray(StandardCharsets.UTF_8)
conn.setFixedLengthStreamingMode(payload.size)
val out: OutputStream = conn.outputStream
out.write(payload)
out.close()
val code = conn.responseCode
drainQuietly(if (code >= 400) conn.errorStream else conn.inputStream)
conn.disconnect()
return code
}
private fun drainQuietly(stream: InputStream?) {
if (stream == null) return
try {
BufferedReader(InputStreamReader(stream, StandardCharsets.UTF_8)).use { reader ->
while (reader.readLine() != null) {
// no-op
}
}
} catch (_: Exception) {
}
}
}
@@ -0,0 +1,13 @@
package ru.forbion.f7cloud.core.push
object PushIntents {
const val EXTRA_OPEN_URL = "ru.forbion.f7cloud.mobile.OPEN_URL"
const val ACTION_OPEN_CALL = "ru.forbion.f7cloud.action.OPEN_CALL"
const val EXTRA_ACCEPT_URL = "ru.forbion.f7cloud.mobile.ACCEPT_URL"
const val EXTRA_ROOM_TOKEN = "ru.forbion.f7cloud.mobile.ROOM_TOKEN"
const val EXTRA_MESSAGE_ID = "ru.forbion.f7cloud.mobile.MESSAGE_ID"
const val EXTRA_AUTO_ACCEPT = "ru.forbion.f7cloud.mobile.AUTO_ACCEPT"
const val EXTRA_CALL_TITLE = "ru.forbion.f7cloud.mobile.CALL_TITLE"
const val EXTRA_CALL_BODY = "ru.forbion.f7cloud.mobile.CALL_BODY"
const val EXTRA_ROOM_DISPLAY_NAME = "ru.forbion.f7cloud.mobile.ROOM_DISPLAY_NAME"
}
@@ -0,0 +1,32 @@
package ru.forbion.f7cloud.core.push
object TalkCallPushLabels {
private val ROOM_IN_TITLE = Regex(
"""(?i)(?:incoming call in|group call (?:has )?started in|входящий звонок в|групповой звонок.*?в)\s+(.+)$""",
)
fun resolveRoomDisplayName(
title: String,
body: String,
roomDisplayName: String?,
): String {
roomDisplayName?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
val trimmedTitle = title.trim()
ROOM_IN_TITLE.find(trimmedTitle)?.groupValues?.getOrNull(1)
?.trim()
?.takeIf { it.isNotBlank() }
?.let { return it }
if (trimmedTitle.isNotBlank() && !trimmedTitle.equals("call", ignoreCase = true)) {
return trimmedTitle
}
val trimmedBody = body.trim()
if (trimmedBody.isNotBlank() && !trimmedBody.equals("call", ignoreCase = true)) {
return trimmedBody
}
return "Звонок"
}
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="notification_channel_messages">F7cloud сообщения</string>
<string name="notification_channel_calls">F7cloud звонки</string>
<string name="call_action_accept">Принять</string>
<string name="call_action_decline">Отклонить</string>
<string name="incoming_call_subtitle">F7cloud звонок</string>
<plurals name="call_queue_waiting">
<item quantity="one">Ещё %d звонок в очереди</item>
<item quantity="few">Ещё %d звонка в очереди</item>
<item quantity="many">Ещё %d звонков в очереди</item>
<item quantity="other">Ещё %d звонков в очереди</item>
</plurals>
</resources>
+3
View File
@@ -0,0 +1,3 @@
plugins {
id 'java-library'
}