Initial import of f7cloud-mobile native Android app.
Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support). Current version: 0.5.113 (build 121).
This commit is contained in:
@@ -0,0 +1,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'
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
@@ -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>)
|
||||
}
|
||||
@@ -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 />
|
||||
+15
@@ -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
|
||||
}
|
||||
}
|
||||
+222
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+72
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -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)
|
||||
}
|
||||
}
|
||||
+108
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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),
|
||||
)
|
||||
+22
@@ -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,
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
@@ -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 200–299. */
|
||||
fun isOcsSuccess(meta: JSONObject?): Boolean {
|
||||
if (meta == null) return false
|
||||
if (meta.optString("status") == "ok") return true
|
||||
val code = meta.optInt("statuscode", 0)
|
||||
return code == 100 || code in 200..299
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
class UnauthorizedException(message: String = "Session expired") : RuntimeException(message)
|
||||
@@ -0,0 +1,25 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
internal fun OkHttpClient.Builder.applyUnsafeSslIfNeeded(trustAllCerts: Boolean): OkHttpClient.Builder {
|
||||
if (!trustAllCerts) return this
|
||||
val trustAll = arrayOf<TrustManager>(
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||
},
|
||||
)
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, trustAll, SecureRandom())
|
||||
val trustManager = trustAll[0] as X509TrustManager
|
||||
sslSocketFactory(sslContext.socketFactory, trustManager)
|
||||
hostnameVerifier { _, _ -> true }
|
||||
return this
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 "Звонок"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
}
|
||||
Reference in New Issue
Block a user