Initial import of f7cloud-mobile native Android app.

Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support).
Current version: 0.5.113 (build 121).
This commit is contained in:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'ru.forbion.f7cloud.feature.talknative'
compileSdk 36
defaultConfig {
minSdk 26
missingDimensionStrategy 'default', 'f7'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
api project(':vendor:talk-app')
implementation project(':core:auth')
implementation project(':core:push')
implementation 'androidx.core:core-ktx:1.15.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1'
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,28 @@
package ru.forbion.f7cloud.feature.talknative
import ru.forbion.f7cloud.core.auth.AuthSession
/** Maps F7cloud [AuthSession] to talk-android account credentials. */
data class TalkAccount(
val serverUrl: String,
val userId: String,
val displayName: String,
val password: String,
val trustAllCerts: Boolean,
)
object TalkAuthBridge {
fun fromSession(session: AuthSession): TalkAccount = TalkAccount(
serverUrl = session.serverUrl.trimEnd('/'),
userId = session.davUserId ?: session.username,
displayName = session.username,
password = session.appPassword,
trustAllCerts = session.trustAllCerts,
)
/** Basic-auth header value for talk-android OkHttp interceptors. */
fun basicAuthHeader(session: AuthSession): String {
val creds = "${session.username}:${session.appPassword}"
return "Basic ${android.util.Base64.encodeToString(creds.toByteArray(), android.util.Base64.NO_WRAP)}"
}
}
@@ -0,0 +1,12 @@
package ru.forbion.f7cloud.feature.talknative
/**
* Room metadata passed into talk-android [ru.f7cloud.talk.activities.CallActivity].
*/
data class TalkNativeCallContext(
val roomToken: String,
val displayName: String = "",
val isOneToOne: Boolean = false,
val joinExistingCall: Boolean = false,
val isVoiceOnly: Boolean = false,
)
@@ -0,0 +1,190 @@
package ru.forbion.f7cloud.feature.talknative
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.util.Log
import android.widget.Toast
import ru.f7cloud.talk.activities.CallActivity
import ru.f7cloud.talk.services.CallForegroundService
import ru.f7cloud.talk.utils.bundle.BundleKeys
import ru.f7cloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_DISPLAY_NAME
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_IS_MODERATOR
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_RECORDING_STATE
import ru.forbion.f7cloud.core.auth.AuthSession
/**
* Launches Talk calls via native WebRTC ([CallActivity]), same as F7cloud Talk Android.
*/
object TalkNativeCallLauncher {
private const val TAG = "TalkNativeCallLauncher"
private const val WEBVIEW_CALL_ACTIVITY = "ru.forbion.f7cloud.feature.talk.TalkCallActivity"
fun launchIncomingCall(
context: Context,
session: AuthSession,
acceptUrl: String,
roomDisplayName: String? = null,
) {
val roomToken = extractRoomToken(stripDirectCallHash(acceptUrl)) ?: run {
showError(context, "Некорректная ссылка звонка")
return
}
launchRoomCall(
context = context,
session = session,
callContext = TalkNativeCallContext(
roomToken = roomToken,
displayName = roomDisplayName.orEmpty(),
joinExistingCall = true,
),
incomingFromNotification = true,
suppressIncomingRingtone = true,
)
}
fun launchRoomCall(context: Context, session: AuthSession, roomToken: String) {
launchRoomCall(
context = context,
session = session,
callContext = TalkNativeCallContext(roomToken = roomToken.trim()),
incomingFromNotification = false,
)
}
fun launchRoomCall(context: Context, session: AuthSession, callContext: TalkNativeCallContext) {
launchRoomCall(
context = context,
session = session,
callContext = callContext,
incomingFromNotification = callContext.joinExistingCall,
)
}
private fun launchRoomCall(
context: Context,
session: AuthSession,
callContext: TalkNativeCallContext,
incomingFromNotification: Boolean,
suppressIncomingRingtone: Boolean = false,
) {
if (!TalkNativeConfig.useNativeWebRtc) {
if (TalkNativeConfig.allowWebViewCallFallback) {
launchWebViewFallback(
context,
session,
callContext.roomToken.trim(),
incomingFromNotification,
)
} else {
showError(context, "Нативные звонки отключены")
}
return
}
if (!TalkVendorBootstrap.ensureSessionReady(context, session)) {
showError(context, "Не удалось подготовить Talk для звонка")
return
}
val token = callContext.roomToken.trim()
if (token.isBlank()) {
showError(context, "Некорректная комната звонка")
return
}
runCatching {
startCallActivity(
context,
buildCallIntent(context, session, callContext, incomingFromNotification, suppressIncomingRingtone),
)
}.onFailure { error ->
Log.e(TAG, "Native CallActivity launch failed", error)
if (TalkNativeConfig.allowWebViewCallFallback) {
launchWebViewFallback(context, session, token, incomingFromNotification)
} else {
showError(context, "Не удалось начать звонок")
}
}
}
private fun buildCallIntent(
context: Context,
session: AuthSession,
callContext: TalkNativeCallContext,
incomingFromNotification: Boolean,
suppressIncomingRingtone: Boolean = false,
): Intent =
Intent(context, CallActivity::class.java).apply {
putExtra(BundleKeys.KEY_ROOM_TOKEN, callContext.roomToken.trim())
putExtra(BundleKeys.KEY_MODIFIED_BASE_URL, session.serverUrl.trimEnd('/'))
val displayName = callContext.displayName.trim()
putExtra(KEY_CONVERSATION_NAME, displayName)
putExtra(KEY_CONVERSATION_DISPLAY_NAME, displayName)
putExtra(BundleKeys.KEY_ROOM_ONE_TO_ONE, callContext.isOneToOne)
putExtra(BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_AUDIO, true)
putExtra(BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO, true)
putExtra(KEY_IS_MODERATOR, true)
putExtra(KEY_RECORDING_STATE, 0)
putExtra(BundleKeys.KEY_CALL_VOICE_ONLY, callContext.isVoiceOnly)
if (incomingFromNotification) {
putExtra(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL, true)
}
if (suppressIncomingRingtone) {
putExtra(BundleKeys.KEY_SUPPRESS_INCOMING_RINGTONE, true)
}
}
private fun startCallActivity(context: Context, intent: Intent) {
val appContext = context.applicationContext
ApplicationWideCurrentRoomHolder.getInstance().clear()
CallForegroundService.stop(appContext)
if (context !is Activity) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// CallActivity is singleTask; onNewIntent restarts when a previous call screen is still alive.
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
context.startActivity(intent)
}
private fun launchWebViewFallback(
context: Context,
session: AuthSession,
roomToken: String,
autoJoin: Boolean,
) {
val callUrl = "${session.serverUrl.trimEnd('/')}/call/$roomToken"
val intent = Intent().apply {
setClassName(context, WEBVIEW_CALL_ACTIVITY)
if (context !is Activity) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
putExtra("url", callUrl)
putExtra("title", if (autoJoin) "Входящий звонок" else "Звонок")
putExtra("username", session.username)
putExtra("password", session.appPassword)
putExtra("server_url", session.serverUrl)
putExtra("trust_all_certs", session.trustAllCerts)
putExtra("auto_join", autoJoin)
}
context.startActivity(intent)
}
private fun showError(context: Context, message: String) {
Toast.makeText(context.applicationContext, message, Toast.LENGTH_LONG).show()
}
private fun stripDirectCallHash(url: String): String {
val hash = url.indexOf('#')
return if (hash >= 0) url.substring(0, hash) else url
}
private fun extractRoomToken(url: String): String? {
val path = runCatching { android.net.Uri.parse(url).path }.getOrNull() ?: url
val marker = "/call/"
val idx = path.indexOf(marker)
if (idx < 0) return null
val rest = path.substring(idx + marker.length)
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it }
return rest.substring(0, end).ifBlank { null }
}
}
@@ -0,0 +1,21 @@
package ru.forbion.f7cloud.feature.talknative
/**
* Feature flags for gradual talk-android integration.
* Vendor source: vendor/talk-android/ (v23.0.0, GPL-3.0-or-later).
*/
object TalkNativeConfig {
/** When true, AppScaffold routes Talk tab through [TalkNativeFacade]. */
var useNativeTalkShell: Boolean = false
/** Sync F7cloud session into talk-android User DB on app start. */
var bootstrapVendorRuntime: Boolean = true
/**
* When true, calls use ru.f7cloud.talk.activities.CallActivity (native WebRTC).
*/
var useNativeWebRtc: Boolean = true
/** Debug-only escape hatch; production uses native CallActivity only. */
var allowWebViewCallFallback: Boolean = false
}
@@ -0,0 +1,16 @@
package ru.forbion.f7cloud.feature.talknative
import ru.forbion.f7cloud.core.auth.AuthSession
/**
* Entry point for talk-android shell integration.
* When [TalkNativeConfig.useNativeTalkShell] is false, the app uses legacy [TalkScreen].
*/
object TalkNativeFacade {
val isNativeShellEnabled: Boolean get() = TalkNativeConfig.useNativeTalkShell
fun accountFor(session: AuthSession): TalkAccount = TalkAuthBridge.fromSession(session)
/** Placeholder for future native Talk list/chat Activities from vendor/talk-android. */
fun nativeShellReady(): Boolean = false
}
@@ -0,0 +1,34 @@
package ru.forbion.f7cloud.feature.talknative
import android.content.Context
import android.content.Intent
import android.util.Log
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.core.push.PushIntents
/**
* Routes f7push call Accept to native WebRTC (when enabled) or WebView fallback.
*/
object TalkPushBridge {
private const val TAG = "TalkPushBridge"
fun launchAcceptedCall(context: Context, acceptUrl: String) {
val session = AuthStore(context).load()
if (session == null) {
Log.w(TAG, "No session for accepted call")
return
}
TalkNativeCallLauncher.launchIncomingCall(context, session, acceptUrl)
}
fun intentForAcceptedCall(context: Context, acceptUrl: String): Intent? {
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null
return launch.apply {
action = PushIntents.ACTION_OPEN_CALL
putExtra(PushIntents.EXTRA_ACCEPT_URL, acceptUrl)
putExtra(PushIntents.EXTRA_AUTO_ACCEPT, true)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
}
}
@@ -0,0 +1,27 @@
package ru.forbion.f7cloud.feature.talknative
import android.content.Context
import android.util.Log
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
/**
* Hooks F7cloud auth into talk-android runtime (User DB, capabilities) for native WebRTC.
*/
object TalkVendorBootstrap {
private const val TAG = "TalkVendorBootstrap"
fun onApplicationCreate(context: Context) {
if (!TalkNativeConfig.bootstrapVendorRuntime) return
val session = AuthStore(context).load() ?: return
runCatching { TalkVendorUserSync.ensureUser(context, session) }
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") }
}
fun ensureSessionReady(context: Context, session: AuthSession): Boolean {
if (!TalkNativeConfig.bootstrapVendorRuntime) return true
return runCatching { TalkVendorUserSync.ensureUser(context, session) }
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") }
.getOrDefault(false)
}
}
@@ -0,0 +1,36 @@
package ru.forbion.f7cloud.feature.talknative
import android.content.Context
import android.util.Log
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.f7cloud.F7TalkUserSync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import ru.forbion.f7cloud.core.auth.AuthSession
/**
* Ensures talk-android [ru.f7cloud.talk.data.user.model.User] exists for F7cloud sessions.
* Required before native [ru.f7cloud.talk.activities.CallActivity] can run.
*/
object TalkVendorUserSync {
private const val TAG = "TalkVendorUserSync"
fun ensureUser(context: Context, session: AuthSession): Boolean {
if (context.applicationContext !is F7cloudTalkApplication) {
Log.w(TAG, "Application is not F7cloudTalkApplication, skip sync")
return false
}
return runBlocking(Dispatchers.IO) {
runCatching {
F7TalkUserSync.get().ensureUser(
serverUrl = session.serverUrl,
username = session.username,
appPassword = session.appPassword,
davUserId = session.davUserId,
)
}
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}", it) }
.getOrNull() != null
}
}
}