Initial import of f7cloud-mobile native Android app.
Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support). Current version: 0.5.113 (build 121).
This commit is contained in:
@@ -0,0 +1,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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user