Initial import of f7cloud-mobile native Android app.

Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support).
Current version: 0.5.113 (build 121).
This commit is contained in:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<activity
android:name=".TalkCallActivity"
android:exported="false"
android:hardwareAccelerated="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".TalkShareActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,320 @@
/**
* F7cloud APK: skip "Check devices" and join incoming calls with camera/mic off.
* Requires sessionStorage f7_apk_direct_join=1 (set from Android on push accept).
*/
(function () {
'use strict';
var LOG = 'F7TalkJoin';
var LEFT_PREFIX = 'f7_apk_left_';
var STYLE_ID = 'f7-talk-hide-media-style';
function log(msg) {
try { console.debug('[' + LOG + '] ' + msg); } catch (e) { /* ignore */ }
}
function roomToken() {
var m = window.location.pathname.match(/\/call\/([A-Za-z0-9]+)/);
return m ? m[1] : null;
}
function leftKey() {
var t = roomToken();
return t ? (LEFT_PREFIX + t) : null;
}
function markLeft() {
try {
var k = leftKey();
if (k) {
sessionStorage.setItem(k, String(Date.now()));
}
sessionStorage.removeItem('f7_apk_direct_join');
document.documentElement.removeAttribute('data-f7-direct-join');
log('marked left');
} catch (e) { /* ignore */ }
}
function leftRecently() {
try {
var k = leftKey();
if (!k) {
return false;
}
var ts = parseInt(sessionStorage.getItem(k) || '0', 10);
return ts > 0 && (Date.now() - ts) < (10 * 60 * 1000);
} catch (e) {
return false;
}
}
function isDirectJoin() {
if (leftRecently()) {
return false;
}
try {
return sessionStorage.getItem('f7_apk_direct_join') === '1';
} catch (e) {
return false;
}
}
function stripDirectCallHash() {
try {
if (window.location.hash === '#direct-call') {
history.replaceState(history.state, '', window.location.pathname + window.location.search);
log('stripped #direct-call hash');
}
} catch (e) { /* ignore */ }
}
function prepareStorage() {
stripDirectCallHash();
try {
localStorage.setItem('showMediaSettings', 'false');
document.documentElement.setAttribute('data-f7-direct-join', '1');
} catch (e) { /* ignore */ }
var token = roomToken();
if (token) {
try {
localStorage.setItem('videoDisabled_' + token, 'true');
localStorage.removeItem('audioDisabled_' + token);
} catch (e2) { /* ignore */ }
}
}
function hideMediaDialogs() {
var roots = document.querySelectorAll('.media-settings');
for (var i = 0; i < roots.length; i++) {
var el = roots[i];
var modal = el.closest('.modal-wrapper')
|| el.closest('.modal-container')
|| el.closest('[role="dialog"]')
|| el;
modal.style.setProperty('display', 'none', 'important');
modal.style.setProperty('visibility', 'hidden', 'important');
modal.style.setProperty('opacity', '0', 'important');
modal.style.setProperty('pointer-events', 'none', 'important');
}
}
function injectHideStyle() {
if (document.getElementById(STYLE_ID)) {
return;
}
var style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = ''
+ 'html[data-f7-direct-join="1"] .modal-wrapper,'
+ 'html[data-f7-direct-join="1"] .modal-container,'
+ 'html[data-f7-direct-join="1"] .media-settings,'
+ 'html[data-f7-direct-join="1"] .modal-wrapper .media-settings'
+ '{display:none!important;visibility:hidden!important;opacity:0!important;pointer-events:none!important;}';
(document.head || document.documentElement).appendChild(style);
hideMediaDialogs();
}
function eventBus() {
if (window._nc_event_bus) {
return window._nc_event_bus;
}
if (window.OC && window.OC._eventBus) {
return window.OC._eventBus;
}
return null;
}
function emitEvent(name, arg) {
var bus = eventBus();
if (!bus || typeof bus.emit !== 'function') {
return false;
}
try {
bus.emit(name, arg === undefined ? '' : arg);
return true;
} catch (e) {
return false;
}
}
function labelMatchesMediaToggle(label) {
return label.indexOf('camera') !== -1 || label.indexOf('video') !== -1
|| label.indexOf('камер') !== -1 || label.indexOf('видео') !== -1
|| label.indexOf('mute video') !== -1 || label.indexOf('turn off') !== -1;
}
function disableCameraInMediaSettings() {
var root = document.querySelector('.media-settings');
if (!root) {
return;
}
var toggles = root.querySelector('.media-settings__toggles');
var scope = toggles || root;
var buttons = scope.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
var b = buttons[i];
var label = ((b.getAttribute('aria-label') || '') + ' ' + (b.getAttribute('title') || '')).toLowerCase();
if (!labelMatchesMediaToggle(label)) {
continue;
}
var pressed = b.getAttribute('aria-pressed');
if (pressed === 'true' || pressed === null) {
b.click();
log('toggled camera off: ' + label);
}
}
}
function clickJoinButtons() {
var root = document.querySelector('.media-settings');
if (root) {
var inDialog = root.querySelectorAll('button.action-button, button.primary, button[class*="join"]');
for (var d = 0; d < inDialog.length; d++) {
var db = inDialog[d];
if (db.disabled || db.offsetParent === null) {
continue;
}
var dt = ((db.getAttribute('aria-label') || '') + ' ' + (db.textContent || '')).toLowerCase();
if (dt.indexOf('join') !== -1 || dt.indexOf('присоедин') !== -1
|| dt.indexOf('answer') !== -1 || dt.indexOf('начать') !== -1
|| dt.indexOf('apply') !== -1 || dt.indexOf('примен') !== -1
|| dt.indexOf('save') !== -1 || dt.indexOf('сохран') !== -1
|| dt.indexOf('готово') !== -1) {
db.click();
log('clicked in-dialog: ' + dt);
return true;
}
}
}
var btn = document.querySelector('button.join-call');
if (btn && !btn.disabled && btn.offsetParent !== null) {
btn.click();
log('clicked button.join-call');
return true;
}
var buttons = document.querySelectorAll('button.action-button, button.primary');
for (var i = 0; i < buttons.length; i++) {
var b = buttons[i];
if (b.disabled || b.offsetParent === null) {
continue;
}
var t = ((b.getAttribute('aria-label') || '') + ' ' + (b.textContent || '')).toLowerCase();
if (t.indexOf('join') !== -1 || t.indexOf('присоедин') !== -1
|| t.indexOf('answer') !== -1 || t.indexOf('ответ') !== -1
|| t.indexOf('принять') !== -1 || t.indexOf('начать') !== -1) {
b.click();
log('clicked: ' + t);
return true;
}
}
return false;
}
function forceJoin() {
if (!isDirectJoin()) {
return false;
}
prepareStorage();
injectHideStyle();
disableCameraInMediaSettings();
emitEvent('talk:media-settings:hide');
if (clickJoinButtons()) {
try { sessionStorage.removeItem('f7_apk_direct_join'); } catch (e) { /* ignore */ }
setTimeout(function () {
document.documentElement.removeAttribute('data-f7-direct-join');
}, 5000);
return true;
}
return false;
}
function installLeaveHook() {
if (window.__f7TalkLeaveHook) {
return;
}
window.__f7TalkLeaveHook = true;
document.addEventListener('click', function (ev) {
var el = ev.target;
if (!el) {
return;
}
for (var i = 0; i < 5 && el; i++) {
if (el.tagName && el.tagName.toLowerCase() === 'button') {
break;
}
el = el.parentElement;
}
if (!el || !el.tagName || el.tagName.toLowerCase() !== 'button') {
return;
}
var txt = ((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || '')).toLowerCase();
if (txt.indexOf('leave') !== -1 || txt.indexOf('hang up') !== -1 || txt.indexOf('end') !== -1
|| txt.indexOf('выйти') !== -1 || txt.indexOf('покин') !== -1 || txt.indexOf('заверш') !== -1) {
markLeft();
}
}, true);
}
function installBusHook() {
if (window.__f7TalkBusHook) {
return;
}
var bus = eventBus();
if (!bus || typeof bus.on !== 'function') {
return;
}
window.__f7TalkBusHook = true;
bus.on('talk:media-settings:show', function () {
if (!isDirectJoin()) {
return;
}
log('block media-settings');
prepareStorage();
injectHideStyle();
setTimeout(function () {
disableCameraInMediaSettings();
emitEvent('talk:media-settings:hide');
forceJoin();
}, 50);
});
}
var tries = 0;
function tick() {
if (!isDirectJoin()) {
return;
}
installLeaveHook();
installBusHook();
injectHideStyle();
tries++;
if (forceJoin() || tries >= 100) {
return;
}
setTimeout(tick, 200);
}
function start() {
prepareStorage();
if (!isDirectJoin()) {
return;
}
if (!roomToken()) {
return;
}
tries = 0;
installLeaveHook();
installBusHook();
tick();
}
window.addEventListener('hashchange', start);
if (document.readyState === 'complete' || document.readyState === 'interactive') {
start();
} else {
document.addEventListener('DOMContentLoaded', start);
window.addEventListener('load', start);
}
})();
@@ -0,0 +1,51 @@
package ru.forbion.f7cloud.feature.talk
import android.content.Context
import android.os.PowerManager
import android.util.Log
/** Keeps the device awake while a Talk call page is open (MIUI / Doze mitigation). */
class F7CallWakeLock(context: Context) {
private val wakeLock: PowerManager.WakeLock =
context.getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "f7cloud:talk_call")
.apply { setReferenceCounted(false) }
private var held = false
fun updateForUrl(url: String?) {
if (TalkHelper.isActiveCallUrl(url)) {
acquire()
} else {
release()
}
}
fun releaseAll() {
release()
}
private fun acquire() {
if (held) return
runCatching {
wakeLock.acquire(4 * 60 * 60 * 1000L)
held = true
Log.d(TAG, "Wake lock acquired for Talk call")
}.onFailure {
Log.w(TAG, "Wake lock acquire failed", it)
}
}
private fun release() {
if (!held) return
runCatching {
if (wakeLock.isHeld) wakeLock.release()
}.onFailure {
Log.w(TAG, "Wake lock release failed", it)
}
held = false
}
companion object {
private const val TAG = "F7CallWakeLock"
}
}
@@ -0,0 +1,17 @@
package ru.forbion.f7cloud.feature.talk
import ru.forbion.f7cloud.core.auth.AuthSession
object TalkAssets {
fun spreed(session: AuthSession, fileName: String): String {
return "${session.serverUrl.trimEnd('/')}/themes/forbion/images/spreed/$fileName"
}
fun header(session: AuthSession, fileName: String): String {
return "${session.serverUrl.trimEnd('/')}/themes/forbion/images/header/$fileName"
}
fun theme(session: AuthSession, fileName: String): String {
return "${session.serverUrl.trimEnd('/')}/themes/forbion/images/$fileName"
}
}
@@ -0,0 +1,203 @@
package ru.forbion.f7cloud.feature.talk
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.OcsUserResolver
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.davFileUrl
import ru.forbion.f7cloud.core.network.davFolderUrl
import java.util.concurrent.TimeUnit
object TalkAttachmentUploader {
private const val MAX_UPLOAD_BYTES = 25L * 1024 * 1024
private const val SHARE_TYPE_ROOM = "10"
private const val DEFAULT_ATTACHMENT_FOLDER = "/Talk"
fun uploadAndShare(
session: AuthSession,
roomToken: String,
fileName: String,
mimeType: String,
bytes: ByteArray,
caption: String = "",
replyTo: Long? = null,
) {
require(fileName.isNotBlank()) { "Имя файла пустое" }
if (bytes.isEmpty()) error("Файл пуст")
if (bytes.size > MAX_UPLOAD_BYTES) {
error("Файл слишком большой (макс. 25 МБ)")
}
val client = uploadClient(session)
val davUserId = OcsUserResolver.resolveDavUserId(session)
val attachmentFolder = fetchAttachmentFolder(client, session)
val remotePath = uniqueRemotePath(client, session, davUserId, attachmentFolder, fileName)
ensureFolderExists(client, session, davUserId, attachmentFolder)
putFile(client, session, davUserId, remotePath, mimeType, bytes)
shareToRoom(client, session, roomToken, remotePath, caption, replyTo)
}
private fun uploadClient(session: AuthSession): OkHttpClient =
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
.newBuilder()
.readTimeout(5, TimeUnit.MINUTES)
.writeTimeout(5, TimeUnit.MINUTES)
.callTimeout(6, TimeUnit.MINUTES)
.build()
private fun fetchAttachmentFolder(client: OkHttpClient, session: AuthSession): String {
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/capabilities?format=json"
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) return DEFAULT_ATTACHMENT_FOLDER
val data = JSONObject(response.body!!.string())
.optJSONObject("ocs")
?.optJSONObject("data")
?.optJSONObject("capabilities")
?.optJSONObject("spreed")
?.optJSONObject("config")
?.optJSONObject("attachments")
val folder = data?.optString("folder").orEmpty().trim()
return folder.ifBlank { DEFAULT_ATTACHMENT_FOLDER }
}
}
private fun uniqueRemotePath(
client: OkHttpClient,
session: AuthSession,
davUserId: String,
attachmentFolder: String,
fileName: String,
): String {
val safeName = fileName.substringAfterLast('/').trim().ifBlank { "file" }
var candidate = joinRemotePath(attachmentFolder, safeName)
var counter = 2
while (fileExists(client, session, davUserId, candidate)) {
val dot = safeName.lastIndexOf('.')
val renamed = if (dot > 0) {
"${safeName.substring(0, dot)} ($counter)${safeName.substring(dot)}"
} else {
"$safeName ($counter)"
}
candidate = joinRemotePath(attachmentFolder, renamed)
counter++
}
return candidate
}
private fun joinRemotePath(folder: String, fileName: String): String {
val base = folder.trim('/').ifBlank { "Talk" }
return "/$base/$fileName"
}
private fun fileExists(
client: OkHttpClient,
session: AuthSession,
davUserId: String,
remotePath: String,
): Boolean {
val url = davFileUrl(session.serverUrl, davUserId, remotePath.trim('/'))
val request = Request.Builder().url(url).head().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
return response.isSuccessful
}
}
private fun ensureFolderExists(
client: OkHttpClient,
session: AuthSession,
davUserId: String,
attachmentFolder: String,
) {
val segments = attachmentFolder.trim('/').split('/').filter { it.isNotBlank() }
var built = ""
for (segment in segments) {
built = if (built.isEmpty()) segment else "$built/$segment"
mkcol(client, session, davUserId, built)
}
}
private fun mkcol(client: OkHttpClient, session: AuthSession, davUserId: String, relativePath: String) {
val url = davFolderUrl(session.serverUrl, davUserId, relativePath).trimEnd('/')
val request = Request.Builder()
.url(url)
.method("MKCOL", ByteArray(0).toRequestBody(null))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
// 405 = already exists
}
}
private fun putFile(
client: OkHttpClient,
session: AuthSession,
davUserId: String,
remotePath: String,
mimeType: String,
bytes: ByteArray,
) {
val mediaType = mimeType.ifBlank { "application/octet-stream" }.toMediaType()
val url = davFileUrl(session.serverUrl, davUserId, remotePath.trim('/'))
val request = Request.Builder()
.url(url)
.put(bytes.toRequestBody(mediaType))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) {
error("Не удалось загрузить файл: HTTP ${response.code}")
}
}
}
private fun shareToRoom(
client: OkHttpClient,
session: AuthSession,
roomToken: String,
remotePath: String,
caption: String,
replyTo: Long?,
) {
val meta = JSONObject()
if (caption.isNotBlank()) meta.put("caption", caption)
if (replyTo != null && replyTo > 0L) meta.put("replyTo", replyTo.toString())
val body = FormBody.Builder()
.add("path", remotePath)
.add("shareWith", roomToken)
.add("shareType", SHARE_TYPE_ROOM)
.add("talkMetaData", meta.toString())
.build()
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/files_sharing/api/v1/shares"
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(body)
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) {
error("Не удалось отправить вложение в чат: HTTP ${response.code}")
}
val ocs = JSONObject(response.body?.string().orEmpty()).optJSONObject("ocs")
if (ocs?.optJSONObject("meta")?.optString("status") == "failure") {
error(ocs.optJSONObject("meta")?.optString("message") ?: "Share failed")
}
}
}
}
@@ -0,0 +1,453 @@
package ru.forbion.f7cloud.feature.talk
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.HttpAuthHandler
import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import okhttp3.OkHttpClient
import okhttp3.Request
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7Theme
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.push.F7IncomingCallQueue
import java.io.FilterInputStream
import java.net.URI
class TalkCallActivity : ComponentActivity() {
private var webViewRef: WebView? = null
private var pageReady by mutableStateOf(false)
private var callWakeLock: F7CallWakeLock? = null
private var pendingAutoJoinUrl: String? = null
private var pendingPermissionRequest: PermissionRequest? = null
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { grants ->
val request = pendingPermissionRequest ?: return@registerForActivityResult
pendingPermissionRequest = null
if (grants.values.all { it }) {
request.grant(request.resources)
} else {
request.deny()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val launch = readLaunch() ?: run {
finish()
return
}
val callUrl = TalkHelper.getCallUrlForWebViewLoad(launch.url)
val roomToken = TalkHelper.extractRoomToken(callUrl)
if (launch.autoJoin) {
pendingAutoJoinUrl = callUrl
F7IncomingCallQueue.dismissAndShowNext(
this,
roomToken ?: TalkHelper.extractRoomToken(launch.url),
)
}
callWakeLock = F7CallWakeLock(this).also { it.updateForUrl(callUrl) }
ensureMediaPermissions()
val httpClient = NetworkFactory.newAuthedClient(
launch.username,
launch.password,
launch.trustAllCerts,
)
val authHosts = buildAuthHosts(launch, callUrl)
setContent {
F7Theme {
Scaffold(
topBar = {
TopAppBar(
title = { Text(launch.title) },
navigationIcon = {
IconButton(onClick = { finish() }) {
Text("", style = MaterialTheme.typography.titleLarge)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = F7Colors.Surface,
titleContentColor = F7Colors.TextPrimary,
),
)
},
containerColor = F7Colors.Background,
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(F7Colors.Surface),
) {
TalkCallWebView(
launch = launch,
callUrl = callUrl,
roomToken = roomToken,
autoJoin = launch.autoJoin,
httpClient = httpClient,
authHosts = authHosts,
onPageReady = { pageReady = true },
onUrlChanged = { url ->
callWakeLock?.updateForUrl(url)
},
)
if (!pageReady) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = F7Colors.Primary)
}
}
}
}
}
}
}
private fun readLaunch(): TalkCallLaunch? {
val url = intent.getStringExtra(EXTRA_URL) ?: return null
val username = intent.getStringExtra(EXTRA_USERNAME).orEmpty()
if (url.isBlank() || username.isBlank()) return null
return TalkCallLaunch(
url = url,
title = intent.getStringExtra(EXTRA_TITLE).orEmpty().ifBlank { "Звонок" },
username = username,
password = intent.getStringExtra(EXTRA_PASSWORD).orEmpty(),
serverUrl = intent.getStringExtra(EXTRA_SERVER_URL).orEmpty(),
trustAllCerts = intent.getBooleanExtra(EXTRA_TRUST_ALL_CERTS, false),
autoJoin = intent.getBooleanExtra(EXTRA_AUTO_JOIN, false),
)
}
private fun ensureMediaPermissions() {
val needed = mutableListOf<String>()
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
needed += Manifest.permission.CAMERA
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
needed += Manifest.permission.RECORD_AUDIO
}
if (needed.isNotEmpty()) {
permissionLauncher.launch(needed.toTypedArray())
}
}
private fun handleWebViewPermissionRequest(request: PermissionRequest) {
val androidPerms = linkedSetOf<String>()
for (resource in request.resources) {
when (resource) {
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> androidPerms += Manifest.permission.CAMERA
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> androidPerms += Manifest.permission.RECORD_AUDIO
}
}
if (androidPerms.isEmpty()) {
request.grant(request.resources)
return
}
val needAsk = androidPerms.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (needAsk.isEmpty()) {
request.grant(request.resources)
return
}
pendingPermissionRequest = request
permissionLauncher.launch(needAsk.toTypedArray())
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun TalkCallWebView(
launch: TalkCallLaunch,
callUrl: String,
roomToken: String?,
autoJoin: Boolean,
httpClient: OkHttpClient,
authHosts: Set<String>,
onPageReady: () -> Unit,
onUrlChanged: (String?) -> Unit,
) {
val serverPrefix = launch.serverUrl.trimEnd('/')
val origin = runCatching { URI(launch.serverUrl).scheme + "://" + URI(launch.serverUrl).host }.getOrNull()
?: serverPrefix
val directJoinJs = if (autoJoin) rememberAssetJs("f7_talk_direct_join.js") else null
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
webViewRef = this
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
CookieManager.getInstance().setAcceptCookie(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
}
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
@Suppress("DEPRECATION")
databaseEnabled = true
mediaPlaybackRequiresUserGesture = false
loadWithOverviewMode = true
useWideViewPort = true
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
userAgentString = TALK_COMPAT_USER_AGENT
}
if (autoJoin) {
evaluateJavascript(TalkHelper.buildDirectJoinDocumentStartScript(), null)
}
webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
runOnUiThread { handleWebViewPermissionRequest(request) }
}
}
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
pageReady = false
onUrlChanged(url)
if (redirectPendingCallIfNeeded(view, url)) return
}
override fun onPageFinished(view: WebView?, url: String?) {
onUrlChanged(url)
val body = buildString {
append(TalkHelper.buildPreconnectScript(origin))
append('\n')
if (autoJoin || pendingAutoJoinUrl != null) {
append(TalkHelper.buildDirectJoinBootstrapScript(roomToken))
} else {
append(TalkHelper.buildNormalCallBootstrapScript(roomToken))
}
}
view?.evaluateJavascript(
TalkWebBranding.jsAfterBranding(launch.serverUrl, body),
null,
)
if ((autoJoin || pendingAutoJoinUrl != null) && !directJoinJs.isNullOrBlank()) {
view?.evaluateJavascript(directJoinJs, null)
}
onPageReady()
}
override fun onReceivedHttpAuthRequest(
view: WebView?,
handler: HttpAuthHandler?,
host: String?,
realm: String?,
) {
if (host != null && authHosts.any { host.equals(it, ignoreCase = true) }) {
handler?.proceed(launch.username, launch.password)
} else {
super.onReceivedHttpAuthRequest(view, handler, host, realm)
}
}
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest,
): WebResourceResponse? {
val url = request.url?.toString() ?: return null
if (!url.startsWith(serverPrefix, ignoreCase = true)) return null
return runCatching {
val builder = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
request.requestHeaders.forEach { (k, v) ->
if (!k.equals("Authorization", ignoreCase = true)) {
builder.header(k, v)
}
}
val response = httpClient.newCall(builder.build()).execute()
if (!response.isSuccessful || response.body == null) {
response.close()
return null
}
val body = response.body!!
val stream = object : FilterInputStream(body.byteStream()) {
override fun close() {
super.close()
response.close()
}
}
WebResourceResponse(
body.contentType()?.let { "${it.type}/${it.subtype}" },
body.contentType()?.charset()?.name() ?: "utf-8",
stream,
)
}.getOrNull()
}
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?,
) {
if (launch.trustAllCerts) {
handler?.proceed()
} else {
super.onReceivedSslError(view, handler, error)
}
}
}
loadUrl(callUrl)
}
},
onRelease = { webViewRef = null },
)
}
private fun redirectPendingCallIfNeeded(view: WebView?, loadingUrl: String?): Boolean {
val pending = pendingAutoJoinUrl ?: return false
if (loadingUrl.isNullOrBlank()) return false
if (TalkHelper.isCallRoomUrl(loadingUrl)) return false
if (!TalkHelper.isPortalHomeUrl(loadingUrl)) return false
view?.stopLoading()
view?.loadUrl(pending)
return true
}
private fun rememberAssetJs(name: String): String? {
return runCatching {
assets.open(name).bufferedReader().use { it.readText() }
}.getOrNull()
}
override fun onDestroy() {
callWakeLock?.releaseAll()
callWakeLock = null
webViewRef?.destroy()
webViewRef = null
super.onDestroy()
}
companion object {
private const val EXTRA_URL = "url"
private const val EXTRA_TITLE = "title"
private const val EXTRA_USERNAME = "username"
private const val EXTRA_PASSWORD = "password"
private const val EXTRA_SERVER_URL = "server_url"
private const val EXTRA_TRUST_ALL_CERTS = "trust_all_certs"
private const val EXTRA_AUTO_JOIN = "auto_join"
private const val TALK_COMPAT_USER_AGENT =
"Mozilla/5.0 (Linux; Android 13; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/131.0.0.0 Mobile Safari/537.36"
fun intent(context: Context, launch: TalkCallLaunch): Intent =
Intent(context, TalkCallActivity::class.java).apply {
putExtra(EXTRA_URL, launch.url)
putExtra(EXTRA_TITLE, launch.title)
putExtra(EXTRA_USERNAME, launch.username)
putExtra(EXTRA_PASSWORD, launch.password)
putExtra(EXTRA_SERVER_URL, launch.serverUrl)
putExtra(EXTRA_TRUST_ALL_CERTS, launch.trustAllCerts)
putExtra(EXTRA_AUTO_JOIN, launch.autoJoin)
}
fun launch(context: Context, session: AuthSession, roomToken: String) {
val url = TalkHelper.buildCallUrl(session.serverUrl, roomToken)
context.startActivity(
intent(
context,
TalkCallLaunch(
url = url,
title = "Звонок",
username = session.username,
password = session.appPassword,
serverUrl = session.serverUrl,
trustAllCerts = session.trustAllCerts,
autoJoin = false,
),
),
)
}
fun launchFromAcceptUrl(context: Context, acceptUrl: String) {
val session = AuthStore(context).load() ?: return
val callUrl = TalkHelper.getCallUrlForWebViewLoad(acceptUrl)
context.startActivity(
intent(
context,
TalkCallLaunch(
url = callUrl,
title = "Входящий звонок",
username = session.username,
password = session.appPassword,
serverUrl = session.serverUrl,
trustAllCerts = session.trustAllCerts,
autoJoin = true,
),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
private fun buildAuthHosts(launch: TalkCallLaunch, callUrl: String): Set<String> {
val hosts = mutableSetOf<String>()
runCatching { URI(launch.url).host }.getOrNull()?.let { hosts += it }
runCatching { URI(callUrl).host }.getOrNull()?.let { hosts += it }
runCatching { URI(launch.serverUrl).host }.getOrNull()?.let { hosts += it }
return hosts
}
}
}
data class TalkCallLaunch(
val url: String,
val title: String,
val username: String,
val password: String,
val serverUrl: String,
val trustAllCerts: Boolean,
val autoJoin: Boolean = false,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
package ru.forbion.f7cloud.feature.talk
/** Parses Talk/spreed deep links from push and web URLs. */
object TalkDeepLink {
fun extractRoomToken(url: String?): String? {
if (url.isNullOrBlank()) return null
val callMarker = "/call/"
val callIdx = url.indexOf(callMarker)
if (callIdx >= 0) {
val rest = url.substring(callIdx + callMarker.length)
return rest.takeWhile { it.isLetterOrDigit() }.ifBlank { null }
}
val spreedMarker = "/apps/spreed/"
val spreedIdx = url.indexOf(spreedMarker)
if (spreedIdx >= 0) {
val rest = url.substring(spreedIdx + spreedMarker.length)
val token = rest.substringBefore('/').substringBefore('?').substringBefore('#')
if (token.isNotBlank() && token != "api") return token
}
return null
}
fun extractMessageId(url: String?): Long? {
if (url.isNullOrBlank()) return null
val fragMarker = "#message_"
val idx = url.indexOf(fragMarker)
if (idx >= 0) {
return url.substring(idx + fragMarker.length).takeWhile { it.isDigit() }.toLongOrNull()
}
return null
}
}
@@ -0,0 +1,15 @@
package ru.forbion.f7cloud.feature.talk
enum class TalkRoomFilter(val label: String) {
ALL("Все"),
UNREAD("Непрочитанные"),
FAVORITES("Избранное"),
MENTIONS("Упоминания"),
}
fun TalkRoom.matchesFilter(filter: TalkRoomFilter): Boolean = when (filter) {
TalkRoomFilter.ALL -> true
TalkRoomFilter.UNREAD -> hasUnread
TalkRoomFilter.FAVORITES -> isFavorite
TalkRoomFilter.MENTIONS -> unreadMention
}
@@ -0,0 +1,185 @@
package ru.forbion.f7cloud.feature.talk
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import ru.forbion.f7cloud.core.designsystem.F7Colors
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
sealed class TalkChatItem {
data class DateSeparator(val label: String) : TalkChatItem()
data object UnreadMarker : TalkChatItem()
data class MessageGroup(
val messages: List<TalkMessage>,
val outgoing: Boolean,
val showAuthor: Boolean,
) : TalkChatItem()
data class SystemLine(val text: String) : TalkChatItem()
}
/**
* Substitutes Talk system-message placeholders ({actor}, {user1}, …) using API messageParameters.
*/
fun formatSystemMessage(
message: String,
parameters: Map<String, TalkMessageParameter>,
fallback: String = "",
): String {
if (message.isBlank()) return fallback
if (parameters.isEmpty()) return message
var result = message
for ((key, param) in parameters) {
val replacement = when (param.type) {
"user", "guest", "call", "email", "user-group", "circle" -> "@${param.name}"
"geo-location" -> param.name
else -> param.name
}
if (replacement.isNotBlank()) {
result = result.replace("{$key}", replacement)
}
}
return result.ifBlank { fallback }
}
fun formatMessageText(raw: String): String {
if (raw.isBlank()) return ""
return raw
.replace(Regex("""!\[[^\]]*]\([^)]*\)"""), "")
.replace(Regex("""\[[^\]]*]\([^)]*\)""")) { it.value.substringAfter('[').substringBefore(']') }
.replace(Regex("""^#{1,6}\s+""", RegexOption.MULTILINE), "")
.replace(Regex("""[*_~`>]"""), "")
.replace(Regex("""\{mention-[^}]+\}"""), "@")
.replace(Regex("""\s+"""), " ")
.trim()
}
fun buildMessageDisplayText(raw: String): AnnotatedString {
if (raw.isBlank()) return AnnotatedString("")
val cleaned = raw
.replace(Regex("""!\[[^\]]*]\([^)]*\)"""), "")
.replace(Regex("""\{mention-[^}]+\}"""), "@")
val mentionPattern = Regex("""@"([^"\s]+)|@"([^"]+)"""")
return buildAnnotatedString {
var last = 0
mentionPattern.findAll(cleaned).forEach { match ->
val start = match.range.first
if (start > last) {
append(cleaned.substring(last, start))
}
append(match.value)
addStyle(
SpanStyle(color = F7Colors.Primary, fontWeight = FontWeight.SemiBold),
start = length - match.value.length,
end = length,
)
last = match.range.last + 1
}
if (last < cleaned.length) {
append(cleaned.substring(last))
}
}
}
fun extractMarkdownImageUrl(raw: String): String? {
val match = Regex("""!\[[^\]]*]\(([^)]+)\)""").find(raw) ?: return null
return match.groupValues.getOrNull(1)?.trim()?.takeIf { it.startsWith("http") }
}
fun formatDateSeparator(epochSeconds: Long): String {
val date = Date(epochSeconds * 1000L)
val today = Calendar.getInstance()
val then = Calendar.getInstance().apply { time = date }
return when {
today.get(Calendar.YEAR) == then.get(Calendar.YEAR) &&
today.get(Calendar.DAY_OF_YEAR) == then.get(Calendar.DAY_OF_YEAR) -> "Сегодня"
today.get(Calendar.YEAR) == then.get(Calendar.YEAR) &&
today.get(Calendar.DAY_OF_YEAR) - then.get(Calendar.DAY_OF_YEAR) == 1 -> "Вчера"
else -> SimpleDateFormat("d MMMM yyyy", Locale("ru")).format(date)
}
}
fun buildChatItems(
messages: List<TalkMessage>,
lastReadMessageId: Long,
selfActorId: String,
collapseSystemMessages: Boolean,
): List<TalkChatItem> {
if (messages.isEmpty()) return emptyList()
val items = mutableListOf<TalkChatItem>()
var lastDate: String? = null
var unreadAdded = false
var pendingSystem = mutableListOf<String>()
var groupMessages = mutableListOf<TalkMessage>()
var groupOutgoing = false
var groupAuthor = ""
fun isOutgoing(message: TalkMessage): Boolean =
message.actorDisplayName.equals("Вы", ignoreCase = true) ||
message.actorDisplayName.equals("You", ignoreCase = true) ||
(message.actorId.isNotBlank() && message.actorId == selfActorId)
fun groupShowAuthor(): Boolean = groupMessages.isNotEmpty() && !groupOutgoing
fun flushSystem() {
if (pendingSystem.isEmpty()) return
if (!collapseSystemMessages) {
pendingSystem.forEach { items += TalkChatItem.SystemLine(it) }
}
pendingSystem = mutableListOf()
}
fun flushGroup() {
if (groupMessages.isEmpty()) return
items += TalkChatItem.MessageGroup(
messages = groupMessages.toList(),
outgoing = groupOutgoing,
showAuthor = groupShowAuthor(),
)
groupMessages = mutableListOf()
}
for (message in messages) {
if (message.isSystemMessage) {
flushGroup()
val line = message.displayText
if (line.isNotBlank()) pendingSystem += line
continue
}
flushSystem()
if (!unreadAdded && lastReadMessageId > 0 && message.id > lastReadMessageId) {
flushGroup()
items += TalkChatItem.UnreadMarker
unreadAdded = true
}
if (message.timestamp > 0) {
val dateKey = formatDateSeparator(message.timestamp)
if (dateKey != lastDate) {
flushGroup()
items += TalkChatItem.DateSeparator(dateKey)
lastDate = dateKey
}
}
val outgoing = isOutgoing(message)
val author = message.actorId.ifBlank { message.actorDisplayName }
if (groupMessages.isEmpty()) {
groupOutgoing = outgoing
groupAuthor = author
groupMessages += message
} else if (outgoing == groupOutgoing && (outgoing || author == groupAuthor)) {
groupMessages += message
} else {
flushGroup()
groupOutgoing = outgoing
groupAuthor = author
groupMessages += message
}
}
flushGroup()
flushSystem()
return items
}
@@ -0,0 +1,155 @@
package ru.forbion.f7cloud.feature.talk
/** Talk / WebRTC helpers (порт F7TalkHelper из android-webview). */
object TalkHelper {
const val HPB_HOST = "hpb-prod.f7cloud.ru"
fun buildPreconnectScript(serverOrigin: String): String {
val origin = serverOrigin.replace("'", "\\'")
val hpb = HPB_HOST.replace("'", "\\'")
return """
(function(){
var h='$hpb',o='$origin';
function link(rel,href){
try{
if(document.querySelector('link[rel="'+rel+'"][href="'+href+'"]'))return;
var l=document.createElement('link');l.rel=rel;l.href=href;
if(rel==='preconnect')l.crossOrigin='anonymous';
(document.head||document.documentElement).appendChild(l);
}catch(e){}
}
link('dns-prefetch','https://'+h);
link('preconnect','https://'+h);
link('dns-prefetch',o);
link('preconnect',o);
})();
""".trimIndent()
}
fun getCallUrlForWebViewLoad(url: String): String {
if (!isCallRoomUrl(url)) return stripDirectCallHash(url)
return runCatching {
val uri = android.net.Uri.parse(url)
val scheme = uri.scheme ?: return stripDirectCallHash(url)
val host = uri.host ?: return stripDirectCallHash(url)
val token = extractRoomTokenFromPath(uri.path) ?: return stripDirectCallHash(url)
val port = uri.port
val base = if (port > 0) "$scheme://$host:$port" else "$scheme://$host"
"$base/call/$token"
}.getOrDefault(stripDirectCallHash(url))
}
fun stripDirectCallHash(url: String): String {
val hash = url.indexOf('#')
return if (hash >= 0) url.substring(0, hash) else url
}
/** Incoming push accept: hide media-settings and join with mic/camera off. */
fun buildDirectJoinDocumentStartScript(): String = """
(function(){try{
localStorage.setItem('showMediaSettings','false');
var m=location.pathname.match(/\/call\/([A-Za-z0-9]+)/);
if(m){
localStorage.setItem('videoDisabled_'+m[1],'true');
localStorage.removeItem('audioDisabled_'+m[1]);
}
if(location.hash==='#direct-call'){
history.replaceState(history.state,'',location.pathname+location.search);
}
}catch(e){}})();
""".trimIndent()
/** Outgoing / manual join: restore Talk pre-call device picker. */
fun buildNormalCallBootstrapScript(roomToken: String?): String {
val tokenPrefs = if (roomToken.isNullOrBlank()) {
""
} else {
val tokenJs = roomToken.replace("'", "\\'")
"localStorage.removeItem('videoDisabled_$tokenJs');" +
"localStorage.removeItem('audioDisabled_$tokenJs');"
}
return """
(function(){try{
sessionStorage.removeItem('f7_apk_direct_join');
document.documentElement.removeAttribute('data-f7-direct-join');
localStorage.removeItem('showMediaSettings');
$tokenPrefs
if(location.hash==='#direct-call'){
history.replaceState(history.state,'',location.pathname+location.search);
}
}catch(e){}})();
""".trimIndent()
}
fun buildDirectJoinBootstrapScript(roomToken: String?): String {
val tokenJs = roomToken?.replace("'", "\\'").orEmpty()
val tokenPrefs = if (tokenJs.isEmpty()) {
""
} else {
"localStorage.setItem('videoDisabled_$tokenJs','true');" +
"localStorage.removeItem('audioDisabled_$tokenJs');"
}
return """
(function(){try{
sessionStorage.setItem('f7_apk_direct_join','1');
localStorage.setItem('showMediaSettings','false');
document.documentElement.setAttribute('data-f7-direct-join','1');
$tokenPrefs
if(location.hash==='#direct-call'){
history.replaceState(history.state,'',location.pathname+location.search);
}
}catch(e){}})();
""".trimIndent()
}
fun extractRoomToken(url: String?): String? {
if (url.isNullOrBlank()) return null
return extractRoomTokenFromPath(android.net.Uri.parse(url).path)
}
private fun extractRoomTokenFromPath(path: String?): String? {
if (path.isNullOrBlank()) return null
val idx = path.indexOf("/call/")
if (idx < 0) return null
val rest = path.substring(idx + 6)
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it }
val token = rest.substring(0, end)
return token.ifBlank { null }
}
fun isCallRoomUrl(url: String?): Boolean = url?.contains("/call/") == true
fun isTalkAppUrl(url: String?): Boolean {
if (url.isNullOrBlank()) return false
return url.contains("/apps/spreed") || isCallRoomUrl(url)
}
fun isPortalHomeUrl(url: String?): Boolean {
if (url.isNullOrBlank()) return false
if (isCallRoomUrl(url)) return false
val uri = android.net.Uri.parse(url)
if (uri.scheme != "http" && uri.scheme != "https") return false
val path = uri.path ?: return false
if (path.isEmpty() || path == "/") return true
return path.contains("/apps/dashboard") ||
path.contains("/login") ||
path.endsWith("/index.php")
}
fun isActiveCallUrl(url: String?): Boolean {
if (url.isNullOrBlank()) return false
if (isCallRoomUrl(url)) return true
return url.contains("/apps/spreed") &&
(url.contains("callUser=") || url.contains("callToken="))
}
fun talkAppWarmupUrl(serverBase: String): String {
val base = serverBase.trimEnd('/')
return "$base/apps/spreed/"
}
fun buildCallUrl(serverBase: String, roomToken: String): String {
val base = serverBase.trimEnd('/')
return "$base/call/${roomToken.trim()}"
}
}
@@ -0,0 +1,27 @@
package ru.forbion.f7cloud.feature.talk
data class TalkMention(
val mentionId: String,
val id: String,
val label: String,
val source: String = "users",
)
fun formatMentionForSend(mention: TalkMention): String {
val id = mention.mentionId.ifBlank { mention.id.ifBlank { mention.label } }
val quoted = id.contains(' ') ||
id.contains('@') ||
id.startsWith("guest/") ||
id.startsWith("group/") ||
id.startsWith("email/") ||
id.startsWith("team/")
return if (quoted) "@\"$id\"" else "@$id"
}
fun insertMentionIntoDraft(draft: String, mention: TalkMention): String {
val atIndex = draft.lastIndexOf('@')
if (atIndex < 0) return draft + formatMentionForSend(mention) + " "
val before = draft.substring(0, atIndex)
val token = formatMentionForSend(mention)
return before + token + " "
}
@@ -0,0 +1,32 @@
package ru.forbion.f7cloud.feature.talk
data class TalkMessage(
val id: Long,
val actorDisplayName: String,
val actorId: String = "",
val text: String,
val timestamp: Long,
val messageType: String = "comment",
val status: Status = Status.SENT,
val replyToId: Long? = null,
val reactions: Map<String, Int> = emptyMap(),
val messageParameters: Map<String, TalkMessageParameter> = emptyMap(),
val attachmentFileId: String? = null,
val attachmentFileName: String? = null,
val attachmentMimeType: String? = null,
val attachmentLink: String? = null,
) {
enum class Status {
SENT,
SENDING,
FAILED,
}
val isSystemMessage: Boolean get() = messageType != "comment"
val isVoiceMessage: Boolean get() = messageType == "voice-message"
val displayText: String
get() = if (isSystemMessage) formatSystemMessage(text, messageParameters, actorDisplayName) else text
val hasImageAttachment: Boolean
get() = attachmentMimeType?.startsWith("image/") == true ||
attachmentFileName.orEmpty().matches(Regex("""(?i)\.(jpg|jpeg|png|gif|webp|bmp)$"""))
}
@@ -0,0 +1,7 @@
package ru.forbion.f7cloud.feature.talk
data class TalkMessageParameter(
val type: String,
val name: String,
val id: String = "",
)
@@ -0,0 +1,24 @@
package ru.forbion.f7cloud.feature.talk
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface TalkOfflineDao {
@Query("SELECT * FROM talk_rooms ORDER BY lastActivity DESC")
suspend fun allRooms(): List<TalkRoomEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertRooms(rooms: List<TalkRoomEntity>)
@Query("SELECT * FROM talk_messages WHERE roomToken = :token ORDER BY id ASC")
suspend fun messagesForRoom(token: String): List<TalkMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertMessages(messages: List<TalkMessageEntity>)
@Query("DELETE FROM talk_messages WHERE roomToken = :token")
suspend fun clearMessages(token: String)
}
@@ -0,0 +1,38 @@
package ru.forbion.f7cloud.feature.talk
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 = [TalkRoomEntity::class, TalkMessageEntity::class],
version = 2,
exportSchema = false,
)
abstract class TalkOfflineDatabase : RoomDatabase() {
abstract fun dao(): TalkOfflineDao
companion object {
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE talk_messages ADD COLUMN messageParametersJson TEXT")
}
}
@Volatile
private var INSTANCE: TalkOfflineDatabase? = null
fun get(context: Context): TalkOfflineDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
TalkOfflineDatabase::class.java,
"f7cloud-talk-offline.db",
).addMigrations(MIGRATION_1_2).build().also { INSTANCE = it }
}
}
}
}
@@ -0,0 +1,31 @@
package ru.forbion.f7cloud.feature.talk
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "talk_rooms")
data class TalkRoomEntity(
@PrimaryKey val token: String,
val displayName: String,
val type: Int,
val unreadMessages: Int,
val hasCall: Boolean,
val isFavorite: Boolean,
val lastActivity: Long,
val lastMessagePreview: String,
val syncedAt: Long,
)
@Entity(tableName = "talk_messages")
data class TalkMessageEntity(
@PrimaryKey val id: Long,
val roomToken: String,
val actorDisplayName: String,
val actorId: String,
val text: String,
val timestamp: Long,
val messageType: String,
val replyToId: Long?,
val reactionsJson: String?,
val messageParametersJson: String? = null,
)
@@ -0,0 +1,131 @@
package ru.forbion.f7cloud.feature.talk
import android.content.Context
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
class TalkOfflineRepository(context: Context) {
private val dao = TalkOfflineDatabase.get(context).dao()
suspend fun cacheRooms(rooms: List<TalkRoom>) {
val now = System.currentTimeMillis()
dao.upsertRooms(
rooms.map { room ->
TalkRoomEntity(
token = room.token,
displayName = room.displayName,
type = room.type,
unreadMessages = room.unreadMessages,
hasCall = room.hasCall,
isFavorite = room.isFavorite,
lastActivity = room.lastActivity,
lastMessagePreview = room.lastMessagePreview,
syncedAt = now,
)
},
)
}
suspend fun cachedRooms(): List<TalkRoom> =
dao.allRooms().map { entity ->
TalkRoom(
token = entity.token,
displayName = entity.displayName,
type = entity.type,
unreadMessages = entity.unreadMessages,
hasCall = entity.hasCall,
isFavorite = entity.isFavorite,
lastActivity = entity.lastActivity,
lastMessagePreview = entity.lastMessagePreview,
)
}
suspend fun cacheMessages(roomToken: String, messages: List<TalkMessage>) {
dao.upsertMessages(
messages.map { msg ->
TalkMessageEntity(
id = msg.id,
roomToken = roomToken,
actorDisplayName = msg.actorDisplayName,
actorId = msg.actorId,
text = msg.text,
timestamp = msg.timestamp,
messageType = msg.messageType,
replyToId = msg.replyToId,
reactionsJson = reactionsToJson(msg.reactions),
messageParametersJson = messageParametersToJson(msg.messageParameters),
)
},
)
}
suspend fun cachedMessages(roomToken: String): List<TalkMessage> =
dao.messagesForRoom(roomToken).map { entity ->
TalkMessage(
id = entity.id,
actorDisplayName = entity.actorDisplayName,
actorId = entity.actorId,
text = entity.text,
timestamp = entity.timestamp,
messageType = entity.messageType,
replyToId = entity.replyToId,
reactions = jsonToReactions(entity.reactionsJson),
messageParameters = jsonToMessageParameters(entity.messageParametersJson),
)
}
private fun reactionsToJson(reactions: Map<String, Int>): String? {
if (reactions.isEmpty()) return null
val o = JSONObject()
reactions.forEach { (emoji, count) -> o.put(emoji, count) }
return o.toString()
}
private fun jsonToReactions(json: String?): Map<String, Int> {
if (json.isNullOrBlank()) return emptyMap()
return runCatching {
val o = JSONObject(json)
buildMap {
o.keys().forEach { key ->
put(key, o.optInt(key, 0))
}
}
}.getOrDefault(emptyMap())
}
private fun messageParametersToJson(parameters: Map<String, TalkMessageParameter>): String? {
if (parameters.isEmpty()) return null
val root = JSONObject()
parameters.forEach { (key, param) ->
root.put(
key,
JSONObject().apply {
put("type", param.type)
put("name", param.name)
if (param.id.isNotBlank()) put("id", param.id)
},
)
}
return root.toString()
}
private fun jsonToMessageParameters(json: String?): Map<String, TalkMessageParameter> {
if (json.isNullOrBlank()) return emptyMap()
return runCatching {
val root = JSONObject(json)
buildMap {
root.keys().forEach { key ->
val obj = root.optJSONObject(key) ?: return@forEach
put(
key,
TalkMessageParameter(
type = obj.optString("type"),
name = obj.optString("name"),
id = obj.optString("id"),
),
)
}
}
}.getOrDefault(emptyMap())
}
}
@@ -0,0 +1,40 @@
package ru.forbion.f7cloud.feature.talk
data class TalkParticipant(
val actorId: String,
val actorType: String,
val displayName: String,
val participantType: Int,
val inCall: Int = 0,
val status: String = "",
val statusMessage: String? = null,
) {
val isInCall: Boolean get() = inCall > 0
val roleLabel: String get() = participantRoleLabel(participantType)
}
data class TalkUserCandidate(
val userId: String,
val displayName: String,
val source: String = "users",
)
enum class CreateRoomMode {
ONE_TO_ONE,
GROUP,
}
fun participantRoleLabel(type: Int): String = when (type) {
1 -> "Владелец"
2 -> "Модератор"
3 -> "Участник"
else -> "Участник"
}
fun userStatusLabel(status: String): String = when (status) {
"online" -> "В сети"
"away" -> "Отошёл"
"dnd" -> "Не беспокоить"
"offline", "" -> "Не в сети"
else -> status
}
@@ -0,0 +1,511 @@
package ru.forbion.f7cloud.feature.talk
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
class TalkRepository {
suspend fun listRooms(session: AuthSession): List<TalkRoom> {
val client = authedClient(session)
val url = spreedApi(session, "v4") + "/room?includeStatus=true&format=json"
val data = getOcsArray(client, url)
val out = mutableListOf<TalkRoom>()
for (i in 0 until data.length()) {
parseRoom(data.optJSONObject(i))?.let { out += it }
}
return out.sortedByDescending { it.lastActivity }
}
suspend fun loadMessages(
session: AuthSession,
roomToken: String,
lastKnownMessageId: Long = 0L,
lookIntoFuture: Boolean = false,
setReadMarker: Boolean = false,
timeoutSeconds: Int = 30,
): List<TalkMessage> {
val client = authedClient(session)
val look = if (lookIntoFuture) 1 else 0
val readMarker = if (setReadMarker) 1 else 0
val url = spreedApi(session, "v1") + "/chat/$roomToken" +
"?lookIntoFuture=$look&limit=50&lastKnownMessageId=$lastKnownMessageId" +
"&setReadMarker=$readMarker&timeout=$timeoutSeconds&format=json"
val data = getOcsArray(client, url)
return parseMessages(data)
}
suspend fun setReadMarker(session: AuthSession, roomToken: String, lastReadMessage: Long? = null) {
val client = authedClient(session)
val url = spreedApi(session, "v1") + "/chat/$roomToken/read?format=json"
val payload = if (lastReadMessage != null) {
JSONObject().put("lastReadMessage", lastReadMessage).toString()
} else {
"{}"
}
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) {
error("Talk read marker failed: HTTP ${response.code}")
}
}
}
suspend fun listParticipants(session: AuthSession, roomToken: String): List<TalkParticipant> {
val client = authedClient(session)
val url = spreedApi(session, "v4") + "/room/$roomToken/participants?includeStatus=true&format=json"
val data = getOcsArray(client, url)
val out = mutableListOf<TalkParticipant>()
for (i in 0 until data.length()) {
parseParticipant(data.optJSONObject(i))?.let { out += it }
}
return out.sortedWith(compareBy({ it.participantType }, { it.displayName.lowercase() }))
}
suspend fun searchMentions(session: AuthSession, roomToken: String, query: String): List<TalkMention> {
val client = authedClient(session)
val encoded = java.net.URLEncoder.encode(query, "UTF-8")
val url = spreedApi(session, "v1") + "/chat/$roomToken/mentions" +
"?search=$encoded&limit=8&includeStatus=true&format=json"
val data = getOcsArray(client, url)
val out = mutableListOf<TalkMention>()
for (i in 0 until data.length()) {
val item = data.optJSONObject(i) ?: continue
val mentionId = item.optString("mentionId").ifBlank { item.optString("id") }
if (mentionId.isBlank()) continue
out += TalkMention(
mentionId = mentionId,
id = item.optString("id"),
label = item.optString("label").ifBlank { mentionId },
source = item.optString("source", "users"),
)
}
return out
}
suspend fun searchUsers(session: AuthSession, query: String): List<TalkUserCandidate> {
val trimmed = query.trim()
if (trimmed.length < 2) return emptyList()
val client = authedClient(session)
val encoded = java.net.URLEncoder.encode(trimmed, "UTF-8")
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/core/autocomplete/get" +
"?search=$encoded&itemType=&itemId=&shareTypes[]=0&shareTypes[]=1&shareTypes[]=2&format=json"
val data = getOcsArray(client, url)
val out = mutableListOf<TalkUserCandidate>()
for (i in 0 until data.length()) {
val item = data.optJSONObject(i) ?: continue
if (item.optString("source") != "users") continue
val userId = item.optString("id")
if (userId.isBlank() || userId == session.username) continue
out += TalkUserCandidate(
userId = userId,
displayName = item.optString("label").ifBlank { userId },
source = item.optString("source", "users"),
)
}
return out.distinctBy { it.userId }
}
suspend fun createOneToOneRoom(session: AuthSession, userId: String): TalkRoom {
val client = authedClient(session)
val url = spreedApi(session, "v4") + "/room?format=json"
val payload = JSONObject()
.put("roomType", 1)
.put("invite", userId)
.put("source", "users")
.toString()
val room = postOcsObject(client, url, payload)
return parseRoom(room) ?: error("Не удалось создать личный чат")
}
suspend fun createGroupRoom(session: AuthSession, name: String, userIds: List<String>): TalkRoom {
val client = authedClient(session)
val url = spreedApi(session, "v4") + "/room?format=json"
val participants = JSONObject()
if (userIds.isNotEmpty()) {
participants.put("users", JSONArray(userIds))
}
val payload = JSONObject()
.put("roomType", 2)
.put("roomName", name.trim())
.put("readOnly", 0)
.put("listable", 0)
.put("lobbyState", 0)
.put("sipEnabled", 0)
.put("permissions", 0)
.put("recordingConsent", 0)
.put("mentionPermissions", 0)
.put("participants", participants)
.toString()
val room = postOcsObject(client, url, payload)
return parseRoom(room) ?: error("Не удалось создать групповой чат")
}
suspend fun setFavorite(session: AuthSession, roomToken: String, favorite: Boolean) {
val client = authedClient(session)
val url = spreedApi(session, "v4") + "/room/$roomToken/favorite?format=json"
val builder = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
if (favorite) {
builder.post("{}".toRequestBody("application/json; charset=utf-8".toMediaType()))
} else {
builder.delete()
}
client.newCall(builder.build()).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Talk favorite failed: HTTP ${response.code}")
}
}
suspend fun markUnread(session: AuthSession, roomToken: String) {
val client = authedClient(session)
val url = spreedApi(session, "v1") + "/chat/$roomToken/read?format=json"
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Talk mark unread failed: HTTP ${response.code}")
}
}
suspend fun addParticipant(session: AuthSession, roomToken: String, userId: String) {
val client = authedClient(session)
val url = spreedApi(session, "v4") + "/room/$roomToken/participants?format=json"
val payload = JSONObject()
.put("newParticipant", userId)
.put("source", "users")
.toString()
postOcsObject(client, url, payload)
}
suspend fun sendMessage(
session: AuthSession,
roomToken: String,
message: String,
replyTo: Long? = null,
) {
val client = authedClient(session)
val url = spreedApi(session, "v1") + "/chat/$roomToken?format=json"
val payload = JSONObject().put("message", message)
if (replyTo != null && replyTo > 0L) {
payload.put("replyTo", replyTo)
}
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(payload.toString().toRequestBody("application/json; charset=utf-8".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code != 201 && !response.isSuccessful) {
error("Talk send failed: HTTP ${response.code}")
}
}
}
suspend fun addReaction(session: AuthSession, roomToken: String, messageId: Long, emoji: String) {
val client = authedClient(session)
val encoded = java.net.URLEncoder.encode(emoji, "UTF-8")
val url = spreedApi(session, "v1") + "/reaction/$roomToken/$messageId?reaction=$encoded&format=json"
postEmpty(client, url, "POST")
}
suspend fun removeReaction(session: AuthSession, roomToken: String, messageId: Long, emoji: String) {
val client = authedClient(session)
val encoded = java.net.URLEncoder.encode(emoji, "UTF-8")
val url = spreedApi(session, "v1") + "/reaction/$roomToken/$messageId?reaction=$encoded&format=json"
postEmpty(client, url, "DELETE")
}
suspend fun searchMessages(session: AuthSession, roomToken: String, query: String): List<TalkMessage> {
val trimmed = query.trim()
if (trimmed.length < 2) return emptyList()
val client = authedClient(session)
val encoded = java.net.URLEncoder.encode(trimmed, "UTF-8")
val url = spreedApi(session, "v1") + "/chat/$roomToken?search=$encoded&limit=30&format=json"
val data = getOcsArray(client, url)
return parseMessages(data)
}
suspend fun getRoom(session: AuthSession, roomToken: String): TalkRoom? {
val client = authedClient(session)
val url = spreedApi(session, "v4") + "/room/$roomToken?format=json"
val ocs = getOcsResponse(client, url)
return parseRoom(ocs.optJSONObject("data"))
}
suspend fun setNotificationLevel(session: AuthSession, roomToken: String, level: Int) {
val client = authedClient(session)
val url = spreedApi(session, "v4") + "/room/$roomToken/notify?format=json"
val payload = JSONObject().put("level", level).toString()
postOcsObject(client, url, payload)
}
private fun postEmpty(client: OkHttpClient, url: String, method: String) {
val builder = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
when (method) {
"POST" -> builder.post("{}".toRequestBody("application/json; charset=utf-8".toMediaType()))
"DELETE" -> builder.delete()
}
client.newCall(builder.build()).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Talk request failed: HTTP ${response.code}")
}
}
suspend fun editMessage(session: AuthSession, roomToken: String, messageId: Long, newText: String) {
val trimmed = newText.trim()
if (trimmed.isEmpty()) error("Сообщение пустое")
val client = authedClient(session)
val url = spreedApi(session, "v1") + "/chat/$roomToken/$messageId?format=json"
val body = FormBody.Builder().add("message", trimmed).build()
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.put(body)
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Talk edit failed: HTTP ${response.code}")
}
}
suspend fun uploadAttachment(
session: AuthSession,
roomToken: String,
fileName: String,
mimeType: String,
bytes: ByteArray,
caption: String = "",
replyTo: Long? = null,
) {
TalkAttachmentUploader.uploadAndShare(
session = session,
roomToken = roomToken,
fileName = fileName,
mimeType = mimeType,
bytes = bytes,
caption = caption,
replyTo = replyTo,
)
}
suspend fun deleteMessage(session: AuthSession, roomToken: String, messageId: Long) {
val client = authedClient(session)
val url = spreedApi(session, "v1") + "/chat/$roomToken/$messageId?format=json"
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Talk delete failed: HTTP ${response.code}")
}
}
private fun authedClient(session: AuthSession): OkHttpClient =
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
private fun spreedApi(session: AuthSession, version: String): String =
"${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/$version"
private fun getOcsArray(client: OkHttpClient, url: String): JSONArray {
val ocs = getOcsResponse(client, url)
return when (val data = ocs.opt("data")) {
is JSONArray -> data
is JSONObject -> JSONArray().put(data)
else -> JSONArray()
}
}
private fun postOcsObject(client: OkHttpClient, url: String, payload: String): JSONObject {
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Talk request failed: HTTP ${response.code}")
}
val ocs = JSONObject(response.body!!.string()).optJSONObject("ocs")
?: error("Talk request failed: invalid response")
if (ocs.optJSONObject("meta")?.optString("status") == "failure") {
error(ocs.optJSONObject("meta")?.optString("message") ?: "Talk request failed")
}
return ocs.optJSONObject("data") ?: JSONObject()
}
}
private fun getOcsResponse(client: OkHttpClient, url: String): JSONObject {
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("Talk request failed: HTTP ${response.code}")
}
val ocs = JSONObject(response.body!!.string()).optJSONObject("ocs")
?: error("Talk request failed: invalid response")
if (ocs.optJSONObject("meta")?.optString("status") == "failure") {
error(ocs.optJSONObject("meta")?.optString("message") ?: "Talk request failed")
}
return ocs
}
}
private fun parseParticipant(item: JSONObject?): TalkParticipant? {
if (item == null) return null
val actorId = item.optString("actorId")
if (actorId.isBlank()) return null
return TalkParticipant(
actorId = actorId,
actorType = item.optString("actorType"),
displayName = item.optString("displayName").ifBlank { actorId },
participantType = item.optInt("participantType", 3),
inCall = item.optInt("inCall", 0),
status = item.optString("status"),
statusMessage = item.optString("statusMessage").ifBlank { null },
)
}
private fun parseRoom(room: JSONObject?): TalkRoom? {
if (room == null) return null
val token = room.optString("token")
if (token.isBlank()) return null
val name = room.optString("displayName").ifBlank { room.optString("name") }
val lastMessage = room.optJSONObject("lastMessage")
val lastParams = parseMessageParameters(lastMessage?.optJSONObject("messageParameters"))
val preview = previewText(lastMessage?.optString("message").orEmpty(), lastParams)
return TalkRoom(
token = token,
displayName = name.ifBlank { token },
type = room.optInt("type", 0),
unreadMessages = room.optInt("unreadMessages", 0),
unreadMention = room.optBoolean("unreadMention", false),
hasCall = room.optBoolean("hasCall", false),
isFavorite = room.optBoolean("isFavorite", false),
lastActivity = room.optLong("lastActivity", 0L),
lastMessagePreview = preview,
lastMessageTimestamp = lastMessage?.optLong("timestamp", 0L) ?: 0L,
actorId = room.optString("actorId"),
actorType = room.optString("actorType"),
lastReadMessage = room.optLong("lastReadMessage", 0L),
)
}
private fun parseMessages(data: JSONArray): List<TalkMessage> {
val out = mutableListOf<TalkMessage>()
for (i in 0 until data.length()) {
val item = data.optJSONObject(i) ?: continue
val id = item.optLong("id", 0L)
val actor = item.optString("actorDisplayName").ifBlank { item.optString("actorId") }
val text = item.optString("message")
val timestamp = item.optLong("timestamp", 0L)
val messageType = item.optString("messageType", "comment")
val replyTo = item.optLong("replyTo", 0L).takeIf { it > 0L }
val reactions = parseReactions(item.optJSONObject("reactions"))
val messageParameters = parseMessageParameters(item.optJSONObject("messageParameters"))
val attachment = parseAttachment(item.optJSONObject("messageParameters"))
if (id > 0L || text.isNotBlank()) {
out += TalkMessage(
id = id,
actorDisplayName = actor,
actorId = item.optString("actorId"),
text = text,
timestamp = timestamp,
messageType = messageType,
status = TalkMessage.Status.SENT,
replyToId = replyTo,
reactions = reactions,
messageParameters = messageParameters,
attachmentFileId = attachment?.fileId,
attachmentFileName = attachment?.fileName,
attachmentMimeType = attachment?.mimeType,
attachmentLink = attachment?.link,
)
}
}
return out.sortedBy { it.id }
}
private data class ParsedAttachment(
val fileId: String?,
val fileName: String?,
val mimeType: String?,
val link: String?,
)
private fun parseMessageParameters(params: JSONObject?): Map<String, TalkMessageParameter> {
if (params == null) return emptyMap()
return buildMap {
params.keys().forEach { key ->
val obj = params.optJSONObject(key) ?: return@forEach
val type = obj.optString("type")
val name = obj.optString("name")
if (name.isBlank() && type != "geo-location") return@forEach
put(
key,
TalkMessageParameter(
type = type,
name = name,
id = obj.optString("id"),
),
)
}
}
}
private fun parseAttachment(params: JSONObject?): ParsedAttachment? {
if (params == null) return null
val file = params.optJSONObject("file") ?: params.optJSONObject("object") ?: return null
return ParsedAttachment(
fileId = file.optString("id").ifBlank { null },
fileName = file.optString("name").ifBlank { file.optString("filename").ifBlank { null } },
mimeType = file.optString("mimetype").ifBlank { file.optString("mimeType").ifBlank { null } },
link = file.optString("link").ifBlank { null },
)
}
private fun parseReactions(obj: JSONObject?): Map<String, Int> {
if (obj == null) return emptyMap()
return buildMap {
obj.keys().forEach { key ->
put(key, obj.optInt(key, 0))
}
}
}
private fun previewText(raw: String, parameters: Map<String, TalkMessageParameter> = emptyMap()): String =
formatMessageText(formatSystemMessage(raw, parameters)).take(120)
}
@@ -0,0 +1,19 @@
package ru.forbion.f7cloud.feature.talk
data class TalkRoom(
val token: String,
val displayName: String,
val type: Int = 0,
val unreadMessages: Int = 0,
val unreadMention: Boolean = false,
val hasCall: Boolean = false,
val isFavorite: Boolean = false,
val lastActivity: Long = 0L,
val lastMessagePreview: String = "",
val lastMessageTimestamp: Long = 0L,
val actorId: String = "",
val actorType: String = "",
val lastReadMessage: Long = 0L,
) {
val hasUnread: Boolean get() = unreadMessages > 0 || unreadMention
}
@@ -0,0 +1,155 @@
package ru.forbion.f7cloud.feature.talk
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
import ru.forbion.f7cloud.core.designsystem.F7TextButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TalkRoomInfoSheet(
visible: Boolean,
session: AuthSession,
state: TalkUiState,
onDismiss: () -> Unit,
onSearch: () -> Unit,
onSearchQueryChange: (String) -> Unit,
onAddParticipant: (String) -> Unit,
onNotificationLevel: (Int) -> Unit,
onLoadParticipants: () -> Unit,
) {
if (!visible) return
val room = state.selectedRoom ?: return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = F7Colors.Surface) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("О комнате", style = MaterialTheme.typography.titleMedium)
Text(room.displayName, style = MaterialTheme.typography.titleSmall)
Text("Тип: ${roomTypeLabel(room.type)}", color = F7Colors.TextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
F7SecondaryButton(text = "Все", onClick = { onNotificationLevel(1) })
F7SecondaryButton(text = "@", onClick = { onNotificationLevel(2) })
F7SecondaryButton(text = "Выкл.", onClick = { onNotificationLevel(3) })
}
F7TextButton(text = "Обновить участников", onClick = onLoadParticipants)
if (state.participants.isNotEmpty()) {
Text("Участники (${state.participants.size})", style = MaterialTheme.typography.labelMedium)
state.participants.take(8).forEach { p ->
Text("${p.displayName}", style = MaterialTheme.typography.bodySmall)
}
}
Spacer(Modifier.height(4.dp))
Text("Поиск по сообщениям", style = MaterialTheme.typography.labelMedium)
OutlinedTextField(
value = state.messageSearchQuery,
onValueChange = onSearchQueryChange,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Минимум 2 символа") },
singleLine = true,
)
F7PrimaryButton(
text = if (state.messageSearchLoading) "Поиск…" else "Найти",
onClick = onSearch,
)
if (state.messageSearchResults.isNotEmpty()) {
LazyColumn(
modifier = Modifier.height(160.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(state.messageSearchResults, key = { it.id }) { msg ->
Text(
"${msg.actorDisplayName}: ${formatMessageText(msg.text).take(80)}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
if (room.type == 2 && state.userSearchResults.isNotEmpty()) {
Text("Добавить участника", style = MaterialTheme.typography.labelMedium)
state.userSearchResults.take(5).forEach { user ->
F7TextButton(
text = "+ ${user.displayName}",
onClick = { onAddParticipant(user.userId) },
)
}
}
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
fun TalkReplyPreviewBar(
message: TalkMessage?,
onClear: () -> Unit,
modifier: Modifier = Modifier,
) {
if (message == null) return
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(F7Colors.Surface)
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.padding(start = 12.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.width(3.dp)
.height(36.dp)
.background(F7Colors.Primary, RoundedCornerShape(2.dp)),
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp),
) {
Text(
"Ответ ${message.actorDisplayName}",
style = MaterialTheme.typography.labelSmall,
color = F7Colors.Primary,
fontWeight = FontWeight.Medium,
)
Text(
formatMessageText(message.text).take(60),
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
}
F7TextButton(text = "", onClick = onClear)
}
}
@@ -0,0 +1,663 @@
package ru.forbion.f7cloud.feature.talk
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.F7TextButton
import ru.forbion.f7cloud.feature.talknative.TalkNativeCallContext
import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
@Composable
fun TalkScreen(
session: AuthSession,
modifier: Modifier = Modifier,
openRoomToken: String? = null,
scrollToMessageId: Long? = null,
chatsListRequest: Int = 0,
pushSyncRequest: Int = 0,
pushRoomToken: String? = null,
onOpenRoomConsumed: () -> Unit = {},
onRoomOpenStateChange: (Boolean) -> Unit = {},
onOpenCalendar: () -> Unit = {},
onUnauthorized: () -> Unit = {},
) {
val context = LocalContext.current
val offlineRepo = remember { TalkOfflineRepository(context) }
val vm: TalkViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
TalkViewModel(offlineRepository = offlineRepo) as T
},
)
val state by vm.state.collectAsState()
var draft by rememberSaveable { mutableStateOf("") }
val callPermissions = remember {
arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
}
var pendingCallContext by remember { mutableStateOf<TalkNativeCallContext?>(null) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { grants ->
val callContext = pendingCallContext
pendingCallContext = null
if (callContext != null && callPermissions.all { grants[it] == true }) {
TalkNativeCallLauncher.launchRoomCall(context, session, callContext)
}
}
LaunchedEffect(session.serverUrl, session.username) {
vm.load(session)
}
LaunchedEffect(openRoomToken, scrollToMessageId) {
if (!openRoomToken.isNullOrBlank()) {
vm.openRoom(session, openRoomToken, scrollToMessageId)
onOpenRoomConsumed()
}
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
LaunchedEffect(chatsListRequest) {
if (chatsListRequest > 0 && state.selectedRoomToken != null) {
vm.closeRoom(session)
}
}
LaunchedEffect(pushSyncRequest) {
if (pushSyncRequest > 0) {
vm.syncFromPush(session, pushRoomToken)
}
}
LaunchedEffect(state.selectedRoomToken) {
onRoomOpenStateChange(state.selectedRoomToken != null)
}
F7OverlayDismissHandler(
enabled = state.selectedRoomToken != null,
onDismiss = { vm.closeRoom(session) },
)
F7OverlayDismissHandler(
enabled = state.showCreateRoom,
onDismiss = vm::closeCreateRoom,
)
F7OverlayDismissHandler(
enabled = state.showParticipants,
onDismiss = vm::closeParticipants,
)
F7OverlayDismissHandler(
enabled = state.showFilterSheet,
onDismiss = vm::closeFilterSheet,
)
F7OverlayDismissHandler(
enabled = state.showRoomInfo,
onDismiss = vm::closeRoomInfo,
)
fun startCall() {
val room = state.selectedRoom ?: return
val callContext = TalkNativeCallContext(
roomToken = room.token,
displayName = room.displayName,
isOneToOne = room.type == 1 || room.type == 3,
joinExistingCall = room.hasCall,
)
val missing = callPermissions.filter {
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) {
TalkNativeCallLauncher.launchRoomCall(context, session, callContext)
} else {
pendingCallContext = callContext
permissionLauncher.launch(missing.toTypedArray())
}
}
TalkCreateRoomSheet(
visible = state.showCreateRoom,
session = session,
mode = state.createRoomMode,
roomName = state.createRoomName,
userSearchQuery = state.userSearchQuery,
userSearchResults = state.userSearchResults,
selectedUsers = state.selectedUsers,
userSearchLoading = state.userSearchLoading,
creating = state.creatingRoom,
onDismiss = vm::closeCreateRoom,
onModeChange = vm::setCreateRoomMode,
onRoomNameChange = vm::setCreateRoomName,
onUserSearchChange = { vm.setUserSearchQuery(session, it) },
onUserToggle = vm::toggleUserSelection,
onCreate = { vm.createRoom(session) },
)
TalkParticipantsSheet(
visible = state.showParticipants,
session = session,
participants = state.participants,
loading = state.participantsLoading,
onDismiss = vm::closeParticipants,
)
TalkFilterSheet(
visible = state.showFilterSheet,
selected = state.roomFilter,
onDismiss = vm::closeFilterSheet,
onFilterSelected = vm::setRoomFilter,
)
TalkRoomInfoSheet(
visible = state.showRoomInfo,
session = session,
state = state,
onDismiss = vm::closeRoomInfo,
onSearch = { vm.searchMessagesInRoom(session) },
onSearchQueryChange = vm::setMessageSearchQuery,
onAddParticipant = { vm.addParticipantToRoom(session, it) },
onNotificationLevel = { vm.setRoomNotificationLevel(session, it) },
onLoadParticipants = { vm.openParticipants(session) },
)
if (state.selectedRoomToken != null) {
val pickAttachment = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
) { uri ->
uri?.let {
val cap = draft.trim()
vm.uploadAttachment(context.applicationContext, session, it, cap)
if (cap.isNotEmpty()) draft = ""
}
}
val voiceRecorder = remember { TalkVoiceRecorder(context.applicationContext) }
var voiceRecording by remember { mutableStateOf(false) }
val audioPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
runCatching { voiceRecorder.start() }
voiceRecording = voiceRecorder.isRecording
}
}
fun toggleVoiceRecording() {
if (voiceRecording) {
val file = voiceRecorder.stop()
voiceRecording = false
if (file != null) {
vm.uploadVoiceRecording(session, file)
}
} else if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
) {
runCatching { voiceRecorder.start() }
voiceRecording = voiceRecorder.isRecording
} else {
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
Box(modifier = modifier.fillMaxSize()) {
if (state.loading && state.messages.isEmpty()) {
CircularProgressIndicator(
color = F7Colors.Primary,
modifier = Modifier
.align(Alignment.Center)
.padding(16.dp),
)
}
if (!state.error.isNullOrBlank()) {
Text(
state.error ?: "",
color = F7Colors.Error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(16.dp),
)
}
ChatContent(
session = session,
state = state,
draft = draft,
onDraftChange = { draft = it },
onSend = {
vm.sendMessage(session, draft)
draft = ""
},
onAttach = { pickAttachment.launch("*/*") },
onVoiceToggle = { toggleVoiceRecording() },
voiceRecording = voiceRecording,
onRetry = { vm.refreshMessages(session) },
onStartCall = { startCall() },
onBackClick = { vm.closeRoom(session) },
onOpenRoomInfo = {
vm.openRoomInfo()
vm.openParticipants(session)
},
onScheduleMeeting = onOpenCalendar,
onToggleSystemMessages = vm::toggleSystemMessagesCollapse,
onReply = { vm.setReplyTo(it) },
onReaction = { msg, emoji -> vm.addReaction(session, msg.id, emoji) },
onDeleteMessage = { vm.deleteMessage(session, it.id) },
onEditMessage = { msg, text -> vm.editMessage(session, msg.id, text) },
onMentionQuery = { vm.setMentionQuery(session, it) },
onMentionSelected = { mention ->
draft = insertMentionIntoDraft(draft, mention)
vm.clearMentionCandidates()
},
onClearReply = { vm.setReplyTo(null) },
onClearHighlight = vm::clearHighlightMessage,
)
}
} else {
F7ModuleScreen(
modifier = modifier,
loading = state.loading && state.rooms.isEmpty(),
error = state.error,
onRefresh = { vm.load(session) },
) {
RoomListContent(
session = session,
rooms = state.filteredRooms,
searchQuery = state.searchQuery,
roomFilter = state.roomFilter,
filterActive = state.activeFilterCount > 0,
onSearchChange = vm::setSearchQuery,
onFilterClick = vm::openFilterSheet,
onClearFilter = { vm.setRoomFilter(TalkRoomFilter.ALL) },
onRoomClick = { vm.openRoom(session, it.token) },
onCreateRoom = vm::openCreateRoom,
onToggleFavorite = { vm.toggleFavorite(session, it.token) },
onMarkUnread = { vm.markRoomUnread(session, it.token) },
)
}
}
}
@Composable
private fun RoomListContent(
session: AuthSession,
rooms: List<TalkRoom>,
searchQuery: String,
roomFilter: TalkRoomFilter,
filterActive: Boolean,
onSearchChange: (String) -> Unit,
onFilterClick: () -> Unit,
onClearFilter: () -> Unit,
onRoomClick: (TalkRoom) -> Unit,
onCreateRoom: () -> Unit,
onToggleFavorite: (TalkRoom) -> Unit,
onMarkUnread: (TalkRoom) -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TalkListToolbar(
session = session,
query = searchQuery,
onQueryChange = onSearchChange,
filterActive = filterActive,
onFilterClick = onFilterClick,
onCreateClick = onCreateRoom,
)
TalkFilterChips(selected = roomFilter, onClear = onClearFilter)
if (rooms.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(
model = TalkAssets.spreed(session, "icon-empty-mail-glass.svg"),
contentDescription = null,
modifier = Modifier.size(64.dp),
contentScale = ContentScale.Fit,
)
Text(
when {
searchQuery.isNotBlank() -> "Ничего не найдено"
roomFilter != TalkRoomFilter.ALL -> "Нет комнат по фильтру"
else -> "Нет комнат"
},
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(rooms, key = { it.token }) { room ->
TalkRoomRow(
room = room,
session = session,
onClick = { onRoomClick(room) },
onToggleFavorite = { onToggleFavorite(room) },
onMarkUnread = { onMarkUnread(room) },
)
}
}
}
}
}
@Composable
private fun ChatContent(
session: AuthSession,
state: TalkUiState,
draft: String,
onDraftChange: (String) -> Unit,
onSend: () -> Unit,
onAttach: () -> Unit,
onVoiceToggle: () -> Unit,
voiceRecording: Boolean,
onRetry: () -> Unit,
onStartCall: () -> Unit,
onBackClick: () -> Unit,
onOpenRoomInfo: () -> Unit,
onScheduleMeeting: () -> Unit,
onToggleSystemMessages: () -> Unit,
onReply: (TalkMessage) -> Unit,
onReaction: (TalkMessage, String) -> Unit,
onDeleteMessage: (TalkMessage) -> Unit,
onEditMessage: (TalkMessage, String) -> Unit,
onMentionQuery: (String?) -> Unit,
onMentionSelected: (TalkMention) -> Unit,
onClearReply: () -> Unit,
onClearHighlight: () -> Unit,
) {
var actionMessage by remember { mutableStateOf<TalkMessage?>(null) }
var pendingDelete by remember { mutableStateOf<TalkMessage?>(null) }
var editingMessage by remember { mutableStateOf<TalkMessage?>(null) }
var editDraft by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val chatItems = remember(state.messages, state.openRoomLastReadMessageId, state.collapseSystemMessages) {
buildChatItems(
messages = state.messages,
lastReadMessageId = state.openRoomLastReadMessageId,
selfActorId = session.username,
collapseSystemMessages = state.collapseSystemMessages,
)
}
val systemMessageCount = remember(state.messages) {
state.messages.count { it.isSystemMessage }
}
val showScrollDown by remember {
derivedStateOf {
if (chatItems.isEmpty()) return@derivedStateOf false
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: 0
lastVisible < info.totalItemsCount - 1
}
}
var stickToBottom by remember { mutableStateOf(true) }
LaunchedEffect(showScrollDown) {
stickToBottom = !showScrollDown
}
LaunchedEffect(chatItems.size, state.highlightMessageId) {
val targetId = state.highlightMessageId ?: return@LaunchedEffect
val idx = chatItems.indexOfFirst { item ->
item is TalkChatItem.MessageGroup && item.messages.any { it.id == targetId }
}
if (idx >= 0) {
listState.animateScrollToItem(idx)
kotlinx.coroutines.delay(2500)
onClearHighlight()
}
}
LaunchedEffect(chatItems.size) {
if (chatItems.isNotEmpty() && stickToBottom) {
listState.animateScrollToItem(chatItems.lastIndex)
}
}
TalkMessageActionSheet(
message = actionMessage,
canModify = actionMessage?.let { msg ->
msg.actorId.isNotBlank() && msg.actorId == session.username && !msg.isSystemMessage
} == true,
onDismiss = { actionMessage = null },
onReply = {
onReply(it)
actionMessage = null
},
onEdit = {
editingMessage = it
editDraft = it.text
actionMessage = null
},
onDelete = {
pendingDelete = it
actionMessage = null
},
onReaction = { msg, emoji ->
onReaction(msg, emoji)
actionMessage = null
},
)
editingMessage?.let { message ->
AlertDialog(
onDismissRequest = { editingMessage = null },
title = { Text("Изменить сообщение") },
text = {
OutlinedTextField(
value = editDraft,
onValueChange = { editDraft = it },
minLines = 2,
maxLines = 6,
)
},
confirmButton = {
TextButton(onClick = {
onEditMessage(message, editDraft)
editingMessage = null
}) {
Text("Сохранить", color = F7Colors.Primary)
}
},
dismissButton = {
TextButton(onClick = { editingMessage = null }) {
Text("Отмена")
}
},
)
}
pendingDelete?.let { message ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Удалить сообщение?") },
text = { Text("Сообщение будет удалено для всех участников.") },
confirmButton = {
TextButton(onClick = {
onDeleteMessage(message)
pendingDelete = null
}) {
Text("Удалить", color = F7Colors.Primary)
}
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text("Отмена")
}
},
)
}
Box(modifier = Modifier.fillMaxSize()) {
TalkChatBackground(
session = session,
modifier = Modifier.fillMaxSize(),
)
Column(
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
state.selectedRoom?.let { room ->
TalkChatHeader(
session = session,
room = room,
participantCount = state.participants.takeIf { it.isNotEmpty() }?.size,
onBackClick = onBackClick,
onStartCall = onStartCall,
onMenuClick = onOpenRoomInfo,
onScheduleMeeting = onScheduleMeeting,
)
}
if (systemMessageCount > 0) {
TalkSystemMessagesToggle(
collapsed = state.collapseSystemMessages,
systemCount = systemMessageCount,
session = session,
onClick = onToggleSystemMessages,
)
}
if (state.messages.any { it.status == TalkMessage.Status.FAILED }) {
F7TextButton(text = "Повторить загрузку", onClick = onRetry)
}
}
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp)
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(pass = PointerEventPass.Initial)
stickToBottom = false
}
},
state = listState,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(chatItems.size, key = { index ->
when (val item = chatItems[index]) {
is TalkChatItem.DateSeparator -> "date-${item.label}-$index"
TalkChatItem.UnreadMarker -> "unread-$index"
is TalkChatItem.SystemLine -> "sys-${item.text}-$index"
is TalkChatItem.MessageGroup -> "grp-${item.messages.first().id}-$index"
}
}) { index ->
when (val item = chatItems[index]) {
is TalkChatItem.DateSeparator -> TalkDateSeparator(item.label)
TalkChatItem.UnreadMarker -> TalkUnreadMarker()
is TalkChatItem.SystemLine -> Text(
item.text,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = F7Colors.TextSecondary,
)
is TalkChatItem.MessageGroup -> TalkMessageGroupRow(
session = session,
group = item,
highlightMessageId = state.highlightMessageId,
onLongPress = { actionMessage = it },
onReaction = onReaction,
)
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.navigationBarsPadding(),
) {
TalkReplyPreviewBar(message = state.replyToMessage, onClear = onClearReply)
TalkComposer(
session = session,
value = draft,
onValueChange = onDraftChange,
onSend = onSend,
onAttach = onAttach,
onVoiceToggle = onVoiceToggle,
voiceRecording = voiceRecording,
mentionCandidates = state.mentionCandidates,
onMentionQuery = onMentionQuery,
onMentionSelected = onMentionSelected,
sending = state.sending,
uploading = state.uploading,
)
}
}
TalkScrollToBottomFab(
session = session,
visible = showScrollDown,
onClick = {
stickToBottom = true
if (chatItems.isNotEmpty()) {
scope.launch { listState.animateScrollToItem(chatItems.lastIndex) }
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp),
)
}
}
@@ -0,0 +1,14 @@
package ru.forbion.f7cloud.feature.talk
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
/** Entry point for Direct Share into F7cloud Talk. */
class TalkShareActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
TalkShareTarget.launch(this, intent)
finish()
}
}
@@ -0,0 +1,26 @@
package ru.forbion.f7cloud.feature.talk
import android.content.Context
import android.content.Intent
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.core.push.PushIntents
/** Handles Android Direct Share into Talk. */
object TalkShareTarget {
fun handleShare(context: Context, intent: Intent): String? {
if (intent.action != Intent.ACTION_SEND) return null
val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim().orEmpty()
if (text.isEmpty()) return null
val session = AuthStore(context).load() ?: return null
// Open Talk tab; message prefill via URL fragment (handled by AppScaffold).
return "${session.serverUrl.trimEnd('/')}/apps/spreed/#share=${java.net.URLEncoder.encode(text, "UTF-8")}"
}
fun launch(context: Context, intent: Intent) {
val url = handleShare(context, intent) ?: return
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return
launch.putExtra(PushIntents.EXTRA_OPEN_URL, url)
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
context.startActivity(launch)
}
}
@@ -0,0 +1,22 @@
package ru.forbion.f7cloud.feature.talk
import ru.forbion.f7cloud.core.auth.AuthSession
import java.net.URLEncoder
object TalkUrls {
fun roomAvatar(session: AuthSession, token: String): String {
val base = session.serverUrl.trimEnd('/')
return "$base/ocs/v2.php/apps/spreed/api/v1/room/$token/avatar"
}
fun userAvatar(session: AuthSession, userId: String, size: Int = 64): String {
val base = session.serverUrl.trimEnd('/')
val encoded = URLEncoder.encode(userId, Charsets.UTF_8.name())
return "$base/index.php/avatar/$encoded/$size"
}
fun filePreview(session: AuthSession, fileId: String, size: Int = 512): String {
val base = session.serverUrl.trimEnd('/')
return "$base/index.php/core/preview?fileId=$fileId&x=$size&y=$size&a=1&mode=cover&forceIcon=0"
}
}
@@ -0,0 +1,753 @@
package ru.forbion.f7cloud.feature.talk
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.isActive
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
import ru.forbion.f7cloud.core.network.UnauthorizedException
data class TalkUiState(
val loading: Boolean = false,
val rooms: List<TalkRoom> = emptyList(),
val messages: List<TalkMessage> = emptyList(),
val selectedRoomToken: String? = null,
val searchQuery: String = "",
val roomFilter: TalkRoomFilter = TalkRoomFilter.ALL,
val showFilterSheet: Boolean = false,
val collapseSystemMessages: Boolean = true,
val openRoomLastReadMessageId: Long = 0L,
val sending: Boolean = false,
val uploading: Boolean = false,
val error: String? = null,
val unauthorized: Boolean = false,
val participants: List<TalkParticipant> = emptyList(),
val participantsLoading: Boolean = false,
val showParticipants: Boolean = false,
val showCreateRoom: Boolean = false,
val createRoomMode: CreateRoomMode = CreateRoomMode.ONE_TO_ONE,
val createRoomName: String = "",
val userSearchQuery: String = "",
val userSearchResults: List<TalkUserCandidate> = emptyList(),
val selectedUsers: List<TalkUserCandidate> = emptyList(),
val userSearchLoading: Boolean = false,
val creatingRoom: Boolean = false,
val replyToMessage: TalkMessage? = null,
val showRoomInfo: Boolean = false,
val messageSearchQuery: String = "",
val messageSearchResults: List<TalkMessage> = emptyList(),
val messageSearchLoading: Boolean = false,
val offlineCached: Boolean = false,
val highlightMessageId: Long? = null,
val mentionCandidates: List<TalkMention> = emptyList(),
) {
val filteredRooms: List<TalkRoom>
get() {
val q = searchQuery.trim()
return rooms.filter { room ->
room.matchesFilter(roomFilter) &&
(q.isEmpty() ||
room.displayName.contains(q, ignoreCase = true) ||
room.lastMessagePreview.contains(q, ignoreCase = true))
}
}
val activeFilterCount: Int
get() = if (roomFilter == TalkRoomFilter.ALL) 0 else 1
val selectedRoom: TalkRoom?
get() = selectedRoomToken?.let { token -> rooms.find { it.token == token } }
}
class TalkViewModel(
private val repository: TalkRepository = TalkRepository(),
private val offlineRepository: TalkOfflineRepository? = null,
) : ViewModel() {
private val _state = MutableStateFlow(TalkUiState())
val state: StateFlow<TalkUiState> = _state.asStateFlow()
private var pollingJob: Job? = null
private var userSearchJob: Job? = null
private var mentionSearchJob: Job? = null
private var lastKnownMessageId: Long = 0L
fun load(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
val cached = offlineRepository?.cachedRooms().orEmpty()
if (cached.isNotEmpty()) {
_state.value = _state.value.copy(rooms = cached, offlineCached = true, loading = true)
}
runCatching { repository.listRooms(session) }
.onSuccess { rooms ->
offlineRepository?.cacheRooms(rooms)
_state.value = _state.value.copy(
loading = false,
rooms = rooms,
error = null,
offlineCached = false,
)
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = if (cached.isEmpty()) t.message else null,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun setSearchQuery(query: String) {
_state.value = _state.value.copy(searchQuery = query)
}
fun setRoomFilter(filter: TalkRoomFilter) {
_state.value = _state.value.copy(roomFilter = filter, showFilterSheet = false)
}
fun openFilterSheet() {
_state.value = _state.value.copy(showFilterSheet = true)
}
fun closeFilterSheet() {
_state.value = _state.value.copy(showFilterSheet = false)
}
fun setReplyTo(message: TalkMessage?) {
_state.value = _state.value.copy(replyToMessage = message)
}
fun setMentionQuery(session: AuthSession, query: String?) {
mentionSearchJob?.cancel()
val token = _state.value.selectedRoomToken
if (query == null || token == null) {
_state.value = _state.value.copy(mentionCandidates = emptyList())
return
}
mentionSearchJob = viewModelScope.launch(Dispatchers.IO) {
delay(200)
runCatching { repository.searchMentions(session, token, query) }
.onSuccess { results ->
_state.value = _state.value.copy(mentionCandidates = results)
}
.onFailure {
_state.value = _state.value.copy(mentionCandidates = emptyList())
}
}
}
fun clearMentionCandidates() {
mentionSearchJob?.cancel()
_state.value = _state.value.copy(mentionCandidates = emptyList())
}
fun openRoomInfo() {
_state.value = _state.value.copy(showRoomInfo = true)
}
fun closeRoomInfo() {
_state.value = _state.value.copy(showRoomInfo = false)
}
fun setMessageSearchQuery(query: String) {
_state.value = _state.value.copy(messageSearchQuery = query)
}
fun searchMessagesInRoom(session: AuthSession) {
val token = _state.value.selectedRoomToken ?: return
val query = _state.value.messageSearchQuery.trim()
if (query.length < 2) {
_state.value = _state.value.copy(messageSearchResults = emptyList())
return
}
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(messageSearchLoading = true)
runCatching { repository.searchMessages(session, token, query) }
.onSuccess { results ->
_state.value = _state.value.copy(
messageSearchResults = results,
messageSearchLoading = false,
)
}
.onFailure { t ->
_state.value = _state.value.copy(
messageSearchLoading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun addReaction(session: AuthSession, messageId: Long, emoji: String) {
val token = _state.value.selectedRoomToken ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.addReaction(session, token, messageId, emoji) }
.onSuccess { refreshMessages(session) }
.onFailure { t ->
_state.value = _state.value.copy(
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun addParticipantToRoom(session: AuthSession, userId: String) {
val token = _state.value.selectedRoomToken ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.addParticipant(session, token, userId) }
.onSuccess { openParticipants(session) }
.onFailure { t ->
_state.value = _state.value.copy(
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun setRoomNotificationLevel(session: AuthSession, level: Int) {
val token = _state.value.selectedRoomToken ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.setNotificationLevel(session, token, level) }
.onFailure { t ->
_state.value = _state.value.copy(
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun toggleSystemMessagesCollapse() {
_state.value = _state.value.copy(collapseSystemMessages = !_state.value.collapseSystemMessages)
}
fun toggleFavorite(session: AuthSession, roomToken: String) {
val room = _state.value.rooms.find { it.token == roomToken } ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.setFavorite(session, roomToken, !room.isFavorite) }
.onSuccess {
_state.value = _state.value.copy(
rooms = _state.value.rooms.map {
if (it.token == roomToken) it.copy(isFavorite = !room.isFavorite) else it
},
)
}
.onFailure { t ->
_state.value = _state.value.copy(error = t.message, unauthorized = t is UnauthorizedException)
}
}
}
fun markRoomUnread(session: AuthSession, roomToken: String) {
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.markUnread(session, roomToken) }
.onSuccess {
runCatching { repository.listRooms(session) }
.onSuccess { rooms -> _state.value = _state.value.copy(rooms = rooms) }
}
.onFailure { t ->
_state.value = _state.value.copy(error = t.message, unauthorized = t is UnauthorizedException)
}
}
}
fun openCreateRoom() {
_state.value = _state.value.copy(
showCreateRoom = true,
createRoomMode = CreateRoomMode.ONE_TO_ONE,
createRoomName = "",
userSearchQuery = "",
userSearchResults = emptyList(),
selectedUsers = emptyList(),
error = null,
)
}
fun closeCreateRoom() {
userSearchJob?.cancel()
_state.value = _state.value.copy(
showCreateRoom = false,
userSearchQuery = "",
userSearchResults = emptyList(),
selectedUsers = emptyList(),
createRoomName = "",
creatingRoom = false,
)
}
fun setCreateRoomMode(mode: CreateRoomMode) {
_state.value = _state.value.copy(
createRoomMode = mode,
selectedUsers = emptyList(),
createRoomName = if (mode == CreateRoomMode.ONE_TO_ONE) "" else _state.value.createRoomName,
)
}
fun setCreateRoomName(name: String) {
_state.value = _state.value.copy(createRoomName = name)
}
fun setUserSearchQuery(session: AuthSession, query: String) {
_state.value = _state.value.copy(userSearchQuery = query)
userSearchJob?.cancel()
if (query.trim().length < 2) {
_state.value = _state.value.copy(userSearchResults = emptyList(), userSearchLoading = false)
return
}
userSearchJob = viewModelScope.launch(Dispatchers.IO) {
delay(300)
_state.value = _state.value.copy(userSearchLoading = true)
runCatching { repository.searchUsers(session, query) }
.onSuccess { results ->
_state.value = _state.value.copy(userSearchResults = results, userSearchLoading = false)
}
.onFailure { t ->
_state.value = _state.value.copy(
userSearchResults = emptyList(),
userSearchLoading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun toggleUserSelection(user: TalkUserCandidate) {
val current = _state.value
val selected = if (current.createRoomMode == CreateRoomMode.ONE_TO_ONE) {
listOf(user)
} else {
if (current.selectedUsers.any { it.userId == user.userId }) {
current.selectedUsers.filter { it.userId != user.userId }
} else {
current.selectedUsers + user
}
}
_state.value = current.copy(selectedUsers = selected)
}
fun createRoom(session: AuthSession) {
val current = _state.value
val selected = current.selectedUsers
if (selected.isEmpty()) {
_state.value = current.copy(error = "Выберите участника")
return
}
if (current.createRoomMode == CreateRoomMode.GROUP && current.createRoomName.trim().isEmpty()) {
_state.value = current.copy(error = "Введите название группы")
return
}
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(creatingRoom = true, error = null)
runCatching {
when (current.createRoomMode) {
CreateRoomMode.ONE_TO_ONE -> repository.createOneToOneRoom(session, selected.first().userId)
CreateRoomMode.GROUP -> repository.createGroupRoom(
session = session,
name = current.createRoomName.trim(),
userIds = selected.map { it.userId },
)
}
}
.onSuccess { room ->
val rooms = runCatching { repository.listRooms(session) }
.getOrDefault(listOf(room) + current.rooms.filter { it.token != room.token })
_state.value = _state.value.copy(
rooms = rooms,
creatingRoom = false,
showCreateRoom = false,
userSearchQuery = "",
userSearchResults = emptyList(),
selectedUsers = emptyList(),
createRoomName = "",
)
openRoom(session, room.token)
}
.onFailure { t ->
_state.value = _state.value.copy(
creatingRoom = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun openParticipants(session: AuthSession) {
val token = _state.value.selectedRoomToken ?: return
_state.value = _state.value.copy(showParticipants = true, participantsLoading = true, error = null)
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.listParticipants(session, token) }
.onSuccess { participants ->
_state.value = _state.value.copy(participants = participants, participantsLoading = false)
}
.onFailure { t ->
_state.value = _state.value.copy(
participantsLoading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun closeParticipants() {
_state.value = _state.value.copy(showParticipants = false)
}
fun openRoom(session: AuthSession, roomToken: String, scrollToMessageId: Long? = null) {
lastKnownMessageId = 0L
val lastRead = _state.value.rooms.find { it.token == roomToken }?.lastReadMessage ?: 0L
viewModelScope.launch(Dispatchers.IO) {
val cached = offlineRepository?.cachedMessages(roomToken).orEmpty()
_state.value = _state.value.copy(
selectedRoomToken = roomToken,
messages = cached,
participants = emptyList(),
showParticipants = false,
openRoomLastReadMessageId = lastRead,
error = null,
replyToMessage = null,
messageSearchQuery = "",
messageSearchResults = emptyList(),
highlightMessageId = scrollToMessageId,
)
refreshMessages(session)
startPolling(session)
}
}
fun closeRoom(session: AuthSession) {
stopPolling()
val token = _state.value.selectedRoomToken
lastKnownMessageId = 0L
_state.value = _state.value.copy(
selectedRoomToken = null,
messages = emptyList(),
participants = emptyList(),
showParticipants = false,
error = null,
)
if (token != null) {
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.setReadMarker(session, token) }
runCatching { repository.listRooms(session) }
.onSuccess { rooms -> _state.value = _state.value.copy(rooms = rooms) }
}
}
}
fun refreshMessages(session: AuthSession) {
val token = _state.value.selectedRoomToken ?: return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
runCatching {
repository.loadMessages(
session = session,
roomToken = token,
lastKnownMessageId = 0L,
lookIntoFuture = false,
setReadMarker = true,
)
}
.onSuccess { msgs ->
lastKnownMessageId = msgs.maxOfOrNull { it.id } ?: 0L
offlineRepository?.cacheMessages(token, msgs)
_state.value = _state.value.copy(loading = false, messages = msgs, error = null)
markRoomReadLocally(token)
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = if (_state.value.messages.isEmpty()) t.message else null,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun syncFromPush(session: AuthSession, roomToken: String?) {
viewModelScope.launch(Dispatchers.IO) {
val openToken = _state.value.selectedRoomToken
if (!roomToken.isNullOrBlank() && roomToken == openToken) {
runCatching {
repository.loadMessages(
session = session,
roomToken = roomToken,
lastKnownMessageId = 0L,
lookIntoFuture = false,
setReadMarker = true,
)
}.onSuccess { msgs ->
lastKnownMessageId = msgs.maxOfOrNull { it.id } ?: lastKnownMessageId
offlineRepository?.cacheMessages(roomToken, msgs)
_state.value = _state.value.copy(messages = msgs, error = null)
markRoomReadLocally(roomToken)
}
}
runCatching { repository.listRooms(session) }
.onSuccess { rooms ->
offlineRepository?.cacheRooms(rooms)
_state.value = _state.value.copy(rooms = rooms, error = null)
}
}
}
fun sendMessage(session: AuthSession, text: String) {
val token = _state.value.selectedRoomToken ?: return
val trimmed = text.trim()
if (trimmed.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
val localId = -System.currentTimeMillis()
val pendingMessage = TalkMessage(
id = localId,
actorDisplayName = "Вы",
text = trimmed,
timestamp = System.currentTimeMillis() / 1000L,
status = TalkMessage.Status.SENDING,
)
_state.value = _state.value.copy(
sending = true,
error = null,
messages = _state.value.messages + pendingMessage,
)
runCatching {
repository.sendMessage(session, token, trimmed, _state.value.replyToMessage?.id)
}
.onSuccess {
val msgs = runCatching {
repository.loadMessages(
session = session,
roomToken = token,
lastKnownMessageId = 0L,
lookIntoFuture = false,
setReadMarker = true,
)
}.getOrDefault(_state.value.messages.filter { it.id != localId })
lastKnownMessageId = msgs.maxOfOrNull { it.id } ?: lastKnownMessageId
offlineRepository?.cacheMessages(token, msgs)
_state.value = _state.value.copy(
sending = false,
messages = msgs,
error = null,
replyToMessage = null,
)
markRoomReadLocally(token)
}
.onFailure { t ->
val updated = _state.value.messages.map { msg ->
if (msg.id == localId) msg.copy(status = TalkMessage.Status.FAILED) else msg
}
_state.value = _state.value.copy(
sending = false,
messages = updated,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun startPolling(session: AuthSession) {
stopPolling()
pollingJob = viewModelScope.launch(Dispatchers.IO) {
while (isActive) {
if (!AppForegroundTracker.isForeground) {
delay(BACKGROUND_POLL_DELAY_MS)
continue
}
val token = _state.value.selectedRoomToken ?: break
try {
val incoming = repository.loadMessages(
session = session,
roomToken = token,
lastKnownMessageId = lastKnownMessageId,
lookIntoFuture = true,
setReadMarker = true,
timeoutSeconds = 25,
)
if (incoming.isNotEmpty()) {
val merged = mergeMessages(_state.value.messages, incoming)
lastKnownMessageId = merged.maxOfOrNull { it.id } ?: lastKnownMessageId
offlineRepository?.cacheMessages(token, merged)
_state.value = _state.value.copy(messages = merged)
markRoomReadLocally(token)
}
} catch (t: Throwable) {
if (t is UnauthorizedException) {
_state.value = _state.value.copy(unauthorized = true, error = t.message)
return@launch
}
delay(POLL_ERROR_DELAY_MS)
}
}
}
}
fun deleteMessage(session: AuthSession, messageId: Long) {
val token = _state.value.selectedRoomToken ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.deleteMessage(session, token, messageId) }
.onSuccess { refreshMessages(session) }
.onFailure { t ->
_state.value = _state.value.copy(
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun editMessage(session: AuthSession, messageId: Long, newText: String) {
val token = _state.value.selectedRoomToken ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.editMessage(session, token, messageId, newText) }
.onSuccess { refreshMessages(session) }
.onFailure { t ->
_state.value = _state.value.copy(
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun uploadVoiceRecording(
session: AuthSession,
file: java.io.File,
) {
val token = _state.value.selectedRoomToken ?: return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(uploading = true, error = null)
runCatching {
val bytes = file.readBytes()
repository.uploadAttachment(
session = session,
roomToken = token,
fileName = file.name,
mimeType = "audio/mp4",
bytes = bytes,
replyTo = _state.value.replyToMessage?.id,
)
file.delete()
}
.onSuccess {
_state.value = _state.value.copy(uploading = false, replyToMessage = null)
refreshMessages(session)
}
.onFailure { t ->
_state.value = _state.value.copy(
uploading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun uploadAttachment(
context: android.content.Context,
session: AuthSession,
uri: android.net.Uri,
caption: String = "",
) {
val token = _state.value.selectedRoomToken ?: return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(uploading = true, error = null)
runCatching {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri).orEmpty()
val fileName = resolveDisplayName(context, uri, mimeType)
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Не удалось прочитать файл")
repository.uploadAttachment(
session = session,
roomToken = token,
fileName = fileName,
mimeType = mimeType,
bytes = bytes,
caption = caption,
replyTo = _state.value.replyToMessage?.id,
)
}
.onSuccess {
_state.value = _state.value.copy(uploading = false, replyToMessage = null)
refreshMessages(session)
}
.onFailure { t ->
_state.value = _state.value.copy(
uploading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun resolveDisplayName(context: android.content.Context, uri: android.net.Uri, mimeType: String): String {
context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (idx >= 0) {
cursor.getString(idx)?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
}
}
}
val ext = android.webkit.MimeTypeMap.getSingleton()
.getExtensionFromMimeType(mimeType)
?.takeIf { it.isNotBlank() }
return if (ext != null) "upload.$ext" else "upload.bin"
}
fun clearHighlightMessage() {
_state.value = _state.value.copy(highlightMessageId = null)
}
private fun markRoomReadLocally(token: String) {
_state.value = _state.value.copy(
rooms = _state.value.rooms.map { room ->
if (room.token == token) {
room.copy(unreadMessages = 0, unreadMention = false)
} else {
room
}
},
)
}
private fun mergeMessages(current: List<TalkMessage>, incoming: List<TalkMessage>): List<TalkMessage> {
if (incoming.isEmpty()) return current
val byId = LinkedHashMap<Long, TalkMessage>()
current.filter { it.id > 0 }.forEach { byId[it.id] = it }
incoming.forEach { byId[it.id] = it }
return byId.values.sortedBy { it.id }
}
private fun stopPolling() {
pollingJob?.cancel()
pollingJob = null
}
override fun onCleared() {
stopPolling()
userSearchJob?.cancel()
mentionSearchJob?.cancel()
super.onCleared()
}
companion object {
private const val BACKGROUND_POLL_DELAY_MS = 5_000L
private const val POLL_ERROR_DELAY_MS = 5_000L
}
}
@@ -0,0 +1,52 @@
package ru.forbion.f7cloud.feature.talk
import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import java.io.File
class TalkVoiceRecorder(private val context: Context) {
private var recorder: MediaRecorder? = null
private var outputFile: File? = null
val isRecording: Boolean get() = recorder != null
fun start(): File {
stop(delete = true)
val file = File(context.cacheDir, "talk-voice-${System.currentTimeMillis()}.m4a")
val mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC)
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
mediaRecorder.setAudioEncodingBitRate(96_000)
mediaRecorder.setAudioSamplingRate(44_100)
mediaRecorder.setOutputFile(file.absolutePath)
mediaRecorder.prepare()
mediaRecorder.start()
recorder = mediaRecorder
outputFile = file
return file
}
fun stop(delete: Boolean = false): File? {
val file = outputFile
runCatching {
recorder?.stop()
}
runCatching {
recorder?.release()
}
recorder = null
outputFile = null
if (delete) {
file?.delete()
return null
}
return file?.takeIf { it.exists() && it.length() > 0 }
}
}
@@ -0,0 +1,22 @@
package ru.forbion.f7cloud.feature.talk
/** Брендинг F7cloud в WebView Talk (как f7cloud_branding.js на сервере). */
object TalkWebBranding {
fun brandingScriptUrl(serverUrl: String): String =
"${serverUrl.trimEnd('/')}/themes/forbion/js/f7cloud_branding.js"
fun jsAfterBranding(serverUrl: String, body: String): String {
val url = brandingScriptUrl(serverUrl).replace("\\", "\\\\").replace("'", "\\'")
return """
(function() {
var run = function() { $body };
if (window.__f7cloudBranding) { run(); return; }
var s = document.createElement('script');
s.src = '$url';
s.onload = run;
s.onerror = run;
(document.head || document.documentElement).appendChild(s);
})();
""".trimIndent()
}
}