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 @@
|
||||
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" />
|
||||
+28
@@ -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)}"
|
||||
}
|
||||
}
|
||||
+12
@@ -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,
|
||||
)
|
||||
+190
@@ -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 }
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
+34
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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)
|
||||
}
|
||||
}
|
||||
+36
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user