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).
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<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.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_CONNECT"
|
||||
android:minSdkVersion="31" />
|
||||
|
||||
<application
|
||||
android:name=".F7MobileApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
tools:replace="android:label,android:theme,android:icon,android:roundIcon">
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="f7cloud_messages_v2" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".OfficeEditorActivity"
|
||||
android:exported="false"
|
||||
android:hardwareAccelerated="true"
|
||||
android:launchMode="singleTop"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden" />
|
||||
<activity
|
||||
android:name=".qr.F7QrScannerActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar" />
|
||||
<activity
|
||||
android:name=".CallIncomingActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar"
|
||||
android:taskAffinity="" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,133 @@
|
||||
package ru.forbion.f7cloud.mobile
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||
import ru.forbion.f7cloud.core.push.F7IncomingCallQueue
|
||||
import ru.forbion.f7cloud.core.push.F7IncomingCallRinger
|
||||
import ru.forbion.f7cloud.core.push.PushIntents
|
||||
import ru.forbion.f7cloud.core.push.TalkCallPushLabels
|
||||
import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
|
||||
|
||||
/**
|
||||
* Full-screen incoming call UI (Telegram-style).
|
||||
* Notification body tap / lock-screen → preview with Accept/Decline.
|
||||
* Notification Accept button → joins call immediately.
|
||||
*/
|
||||
class CallIncomingActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON,
|
||||
)
|
||||
|
||||
val launch = parseLaunch(intent)
|
||||
if (launch == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if (launch.autoAccept) {
|
||||
acceptCall(launch)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
declineCall(launch)
|
||||
finish()
|
||||
}
|
||||
|
||||
setContent {
|
||||
F7Theme {
|
||||
IncomingCallScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
callerName = launch.displayName,
|
||||
subtitle = launch.subtitle,
|
||||
onAccept = {
|
||||
acceptCall(launch)
|
||||
finish()
|
||||
},
|
||||
onDecline = {
|
||||
declineCall(launch)
|
||||
finish()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
val launch = parseLaunch(intent) ?: return
|
||||
if (launch.autoAccept) {
|
||||
acceptCall(launch)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun acceptCall(launch: IncomingCallLaunch) {
|
||||
F7IncomingCallRinger.stop()
|
||||
val session = AuthStore(this).load() ?: return
|
||||
F7IncomingCallQueue.dismissAndShowNext(this, launch.roomToken)
|
||||
TalkNativeCallLauncher.launchIncomingCall(
|
||||
this,
|
||||
session,
|
||||
launch.acceptUrl,
|
||||
roomDisplayName = launch.displayName,
|
||||
)
|
||||
}
|
||||
|
||||
private fun declineCall(launch: IncomingCallLaunch) {
|
||||
F7IncomingCallQueue.dismissAndShowNext(this, launch.roomToken)
|
||||
}
|
||||
|
||||
private fun parseLaunch(intent: Intent?): IncomingCallLaunch? {
|
||||
if (intent == null) return null
|
||||
val acceptUrl = intent.getStringExtra(PushIntents.EXTRA_ACCEPT_URL)?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
return IncomingCallLaunch(
|
||||
acceptUrl = acceptUrl,
|
||||
roomToken = intent.getStringExtra(PushIntents.EXTRA_ROOM_TOKEN),
|
||||
title = intent.getStringExtra(PushIntents.EXTRA_CALL_TITLE).orEmpty(),
|
||||
displayName = intent.getStringExtra(PushIntents.EXTRA_ROOM_DISPLAY_NAME)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: TalkCallPushLabels.resolveRoomDisplayName(
|
||||
intent.getStringExtra(PushIntents.EXTRA_CALL_TITLE).orEmpty(),
|
||||
intent.getStringExtra(PushIntents.EXTRA_CALL_BODY).orEmpty(),
|
||||
null,
|
||||
),
|
||||
subtitle = intent.getStringExtra(PushIntents.EXTRA_CALL_BODY)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: getString(ru.forbion.f7cloud.core.push.R.string.incoming_call_subtitle),
|
||||
autoAccept = intent.getBooleanExtra(PushIntents.EXTRA_AUTO_ACCEPT, false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class IncomingCallLaunch(
|
||||
val acceptUrl: String,
|
||||
val roomToken: String?,
|
||||
val title: String,
|
||||
val displayName: String,
|
||||
val subtitle: String,
|
||||
val autoAccept: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
package ru.forbion.f7cloud.mobile
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import ru.f7cloud.talk.application.F7cloudTalkApplication
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
|
||||
import ru.forbion.f7cloud.core.push.F7NotificationChannels
|
||||
import ru.forbion.f7cloud.core.push.F7PushRegistrar
|
||||
import ru.forbion.f7cloud.feature.talknative.TalkVendorBootstrap
|
||||
|
||||
/**
|
||||
* F7cloud application entry. Extends talk-android [F7cloudTalkApplication] so native
|
||||
* WebRTC (CallActivity, Dagger, Room) can initialize when [TalkVendorBootstrap] is enabled.
|
||||
*/
|
||||
class F7MobileApp : F7cloudTalkApplication(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
AppForegroundTracker.setForeground(true)
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
AppForegroundTracker.setForeground(false)
|
||||
}
|
||||
})
|
||||
F7NotificationChannels.ensureAll(this)
|
||||
TalkVendorBootstrap.onApplicationCreate(this)
|
||||
val auth = AuthStore(this).load() ?: return
|
||||
try {
|
||||
bootstrapFcmRegistration(auth)
|
||||
} catch (t: Throwable) {
|
||||
Log.w("F7MobileApp", "Firebase unavailable, push bootstrap skipped", t)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-register after Firebase config changes (e.g. google-services.json for .mobile package).
|
||||
*/
|
||||
private fun bootstrapFcmRegistration(auth: ru.forbion.f7cloud.core.auth.AuthSession) {
|
||||
val prefs = getSharedPreferences("f7push", MODE_PRIVATE)
|
||||
val configGeneration = 2
|
||||
val needsRefresh = prefs.getInt("fcm_config_generation", 0) < configGeneration
|
||||
|
||||
fun registerToken(token: String) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val code = F7PushRegistrar.registerBlocking(this@F7MobileApp, auth, token)
|
||||
Log.d("F7MobileApp", "push register result: $code")
|
||||
}
|
||||
}
|
||||
|
||||
if (needsRefresh) {
|
||||
FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener {
|
||||
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
|
||||
prefs.edit().putInt("fcm_config_generation", configGeneration).apply()
|
||||
registerToken(token)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
FirebaseMessaging.getInstance().token.addOnSuccessListener { registerToken(it) }
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.components { add(SvgDecoder.Factory()) }
|
||||
.crossfade(false)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.12)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(cacheDir.resolve("coil_image_cache"))
|
||||
.maxSizePercent(0.02)
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package ru.forbion.f7cloud.mobile
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private val IncomingBgTop = Color(0xFF3D8FD1)
|
||||
private val IncomingBgBottom = Color(0xFF1B4F82)
|
||||
private val AvatarFill = Color(0xFF5BA8E8)
|
||||
private val AcceptGreen = Color(0xFF2ECC71)
|
||||
private val DeclineRed = Color(0xFFE74C3C)
|
||||
|
||||
@Composable
|
||||
fun IncomingCallScreen(
|
||||
callerName: String,
|
||||
subtitle: String,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val displayName = callerName.ifBlank { "Звонок" }
|
||||
val initial = displayName.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(IncomingBgTop, IncomingBgBottom),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(72.dp))
|
||||
IncomingAvatar(initial = initial)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
Text(
|
||||
text = displayName,
|
||||
color = Color.White,
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = Color.White.copy(alpha = 0.82f),
|
||||
fontSize = 16.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Row(
|
||||
modifier = Modifier.padding(bottom = 56.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(72.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IncomingCallAction(
|
||||
label = "Принять",
|
||||
background = AcceptGreen,
|
||||
iconRes = R.drawable.ic_call_accept,
|
||||
onClick = onAccept,
|
||||
)
|
||||
IncomingCallAction(
|
||||
label = "Отклонить",
|
||||
background = DeclineRed,
|
||||
iconRes = R.drawable.ic_call_decline,
|
||||
onClick = onDecline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IncomingAvatar(initial: String) {
|
||||
val transition = rememberInfiniteTransition(label = "ring")
|
||||
val ringAlpha by transition.animateFloat(
|
||||
initialValue = 0.45f,
|
||||
targetValue = 0.08f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1800, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "ringAlpha",
|
||||
)
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(168.dp)
|
||||
.alpha(ringAlpha)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.18f)),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(136.dp)
|
||||
.alpha(ringAlpha * 0.8f)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.14f)),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(112.dp)
|
||||
.clip(CircleShape)
|
||||
.background(AvatarFill),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = initial,
|
||||
color = Color.White,
|
||||
fontSize = 44.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IncomingCallAction(
|
||||
label: String,
|
||||
background: Color,
|
||||
iconRes: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(background),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = label,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(30.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package ru.forbion.f7cloud.mobile
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
import ru.forbion.f7cloud.core.push.F7PushIntentExtras
|
||||
import ru.forbion.f7cloud.core.push.PushIntents
|
||||
import ru.forbion.f7cloud.feature.talk.TalkHelper
|
||||
import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
|
||||
import ru.forbion.f7cloud.mobile.permissions.F7AppPermissions
|
||||
import ru.forbion.f7cloud.mobile.ui.AppScaffold
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
private var openUrl by mutableStateOf<String?>(null)
|
||||
|
||||
private val runtimePermissionsLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { results ->
|
||||
Log.d(TAG, "Runtime permissions result: $results")
|
||||
onPermissionsResult?.invoke()
|
||||
onPermissionsResult = null
|
||||
}
|
||||
|
||||
/** Вызывается после системного диалога разрешений (для регистрации FCM и т.д.). */
|
||||
var onPermissionsResult: (() -> Unit)? = null
|
||||
|
||||
fun launchMissingRuntimePermissions(onFinished: (() -> Unit)? = null) {
|
||||
val missing = F7AppPermissions.missing(this)
|
||||
if (missing.isEmpty()) {
|
||||
onFinished?.invoke()
|
||||
return
|
||||
}
|
||||
onPermissionsResult = onFinished
|
||||
runtimePermissionsLauncher.launch(missing.toTypedArray())
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
if (!handleIncomingIntent(intent)) {
|
||||
openUrl = resolveOpenUrl(intent)
|
||||
}
|
||||
setContent {
|
||||
AppScaffold(
|
||||
openUrl = openUrl,
|
||||
onOpenUrlConsumed = { openUrl = null },
|
||||
onRequestRuntimePermissions = { finished ->
|
||||
launchMissingRuntimePermissions(finished)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
if (!handleIncomingIntent(intent)) {
|
||||
openUrl = resolveOpenUrl(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveOpenUrl(intent: Intent?): String? {
|
||||
if (intent == null) return null
|
||||
if (F7PushIntentExtras.isFcmLaunch(intent)) {
|
||||
F7PushIntentExtras.publishEventFromIntent(intent)
|
||||
}
|
||||
if (intent.data?.toString() == "f7cloud://talk") {
|
||||
val session = AuthStore(this).load() ?: return null
|
||||
return "${session.serverUrl.trimEnd('/')}/apps/spreed/"
|
||||
}
|
||||
val url = F7PushIntentExtras.resolveOpenUrl(intent)
|
||||
val room = F7PushIntentExtras.resolveRoomToken(intent)
|
||||
val messageId = F7PushIntentExtras.resolveMessageId(intent)
|
||||
if (!url.isNullOrBlank()) {
|
||||
if (!messageId.isNullOrBlank() && !url.contains("#message_")) {
|
||||
return "$url#message_$messageId"
|
||||
}
|
||||
return url
|
||||
}
|
||||
if (!room.isNullOrBlank()) {
|
||||
val session = AuthStore(this).load() ?: return null
|
||||
val base = "${session.serverUrl.trimEnd('/')}/index.php/apps/spreed/$room"
|
||||
return if (!messageId.isNullOrBlank()) "$base#message_$messageId" else base
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** @return true if intent was fully handled (call launched). */
|
||||
private fun handleIncomingIntent(intent: Intent?): Boolean {
|
||||
if (intent == null) return false
|
||||
val session = AuthStore(this).load()
|
||||
when {
|
||||
intent.action == PushIntents.ACTION_OPEN_CALL -> {
|
||||
val acceptUrl = intent.getStringExtra(PushIntents.EXTRA_ACCEPT_URL)
|
||||
if (!acceptUrl.isNullOrBlank() && session != null) {
|
||||
if (intent.getBooleanExtra(PushIntents.EXTRA_AUTO_ACCEPT, false)) {
|
||||
TalkNativeCallLauncher.launchIncomingCall(this, session, acceptUrl)
|
||||
} else {
|
||||
startActivity(
|
||||
Intent(intent).apply {
|
||||
setClass(this@MainActivity, CallIncomingActivity::class.java)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
},
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val url = intent.getStringExtra(PushIntents.EXTRA_OPEN_URL)
|
||||
if (!url.isNullOrBlank() && TalkHelper.isCallRoomUrl(url) && session != null) {
|
||||
TalkNativeCallLauncher.launchIncomingCall(this, session, url)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package ru.forbion.f7cloud.mobile
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Message
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
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.compose.foundation.background
|
||||
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.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.LaunchedEffect
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import kotlinx.coroutines.delay
|
||||
import okhttp3.OkHttpClient
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||
|
||||
class OfficeEditorActivity : ComponentActivity() {
|
||||
|
||||
private var webViewRef: WebView? = null
|
||||
private var editorVisible by mutableStateOf(false)
|
||||
private var loadError by mutableStateOf<String?>(null)
|
||||
private var launch by mutableStateOf<OfficeEditorLaunch?>(null)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val initial = readLaunch() ?: run {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
launch = initial
|
||||
bindUi(initial)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
val next = readLaunch() ?: return
|
||||
if (OfficeWebViewPool.sessionKey(next) != launch?.let { OfficeWebViewPool.sessionKey(it) }) {
|
||||
OfficeWebViewPool.dispose()
|
||||
webViewRef = null
|
||||
}
|
||||
launch = next
|
||||
editorVisible = false
|
||||
loadError = null
|
||||
loadDocument(next)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun bindUi(initial: OfficeEditorLaunch) {
|
||||
val httpClient = NetworkFactory.newAuthedClientForOffice(
|
||||
initial.username,
|
||||
initial.password,
|
||||
initial.trustAllCerts,
|
||||
)
|
||||
val authHosts = OfficeWebViewClient.buildAuthHosts(initial)
|
||||
val interceptPrefixes = OfficeWebViewClient.buildInterceptPrefixes(initial)
|
||||
|
||||
setContent {
|
||||
F7Theme {
|
||||
val currentLaunch = launch
|
||||
if (currentLaunch == null) return@F7Theme
|
||||
|
||||
LaunchedEffect(currentLaunch.url, editorVisible) {
|
||||
if (editorVisible) return@LaunchedEffect
|
||||
delay(LOAD_TIMEOUT_MS)
|
||||
if (!editorVisible && loadError == null) {
|
||||
loadError = "Редактор не ответил вовремя. Проверьте интернет и попробуйте снова."
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = currentLaunch.title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
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(androidx.compose.ui.graphics.Color.White),
|
||||
) {
|
||||
if (loadError != null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
loadError!!,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
F7PrimaryButton(
|
||||
text = "Закрыть",
|
||||
onClick = { finish() },
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
OfficeWebView(
|
||||
launch = currentLaunch,
|
||||
httpClient = httpClient,
|
||||
authHosts = authHosts,
|
||||
interceptPrefixes = interceptPrefixes,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
if (!editorVisible) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(androidx.compose.ui.graphics.Color.White),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
Text(
|
||||
"Загрузка редактора…",
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onCollaboraDocumentLoaded(webView: WebView?) {
|
||||
editorVisible = true
|
||||
loadError = null
|
||||
webView?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
|
||||
}
|
||||
|
||||
private fun loadDocument(target: OfficeEditorLaunch) {
|
||||
val view = webViewRef ?: return
|
||||
val current = view.url?.trimEnd('/').orEmpty()
|
||||
val next = target.url.trimEnd('/')
|
||||
if (current == next) return
|
||||
editorVisible = false
|
||||
loadError = null
|
||||
view.loadUrl(target.url)
|
||||
}
|
||||
|
||||
private fun readLaunch(): OfficeEditorLaunch? {
|
||||
val url = intent.getStringExtra(EXTRA_URL) ?: return null
|
||||
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
|
||||
val username = intent.getStringExtra(EXTRA_USERNAME).orEmpty()
|
||||
val password = intent.getStringExtra(EXTRA_PASSWORD).orEmpty()
|
||||
val serverUrl = intent.getStringExtra(EXTRA_SERVER_URL).orEmpty()
|
||||
if (url.isBlank() || username.isBlank()) return null
|
||||
return OfficeEditorLaunch(
|
||||
url = url,
|
||||
title = title,
|
||||
username = username,
|
||||
password = password,
|
||||
trustAllCerts = intent.getBooleanExtra(EXTRA_TRUST_ALL_CERTS, false),
|
||||
serverUrl = serverUrl,
|
||||
collaboraBaseUrl = intent.getStringExtra(EXTRA_COLLABORA_URL).orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
private fun OfficeWebView(
|
||||
launch: OfficeEditorLaunch,
|
||||
httpClient: OkHttpClient,
|
||||
authHosts: Set<String>,
|
||||
interceptPrefixes: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
OfficeWebViewPool.obtain(context, launch) { webView ->
|
||||
configureOfficeWebView(
|
||||
webView = webView,
|
||||
launch = launch,
|
||||
httpClient = httpClient,
|
||||
authHosts = authHosts,
|
||||
interceptPrefixes = interceptPrefixes,
|
||||
)
|
||||
}.also { webView ->
|
||||
webViewRef = webView
|
||||
webView.layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
val current = webView.url?.trimEnd('/').orEmpty()
|
||||
val target = launch.url.trimEnd('/')
|
||||
if (current != target) {
|
||||
webView.loadUrl(launch.url)
|
||||
}
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
webViewRef = webView
|
||||
},
|
||||
onRelease = { webViewRef = null },
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun configureOfficeWebView(
|
||||
webView: WebView,
|
||||
launch: OfficeEditorLaunch,
|
||||
httpClient: OkHttpClient,
|
||||
authHosts: Set<String>,
|
||||
interceptPrefixes: List<String>,
|
||||
) {
|
||||
webView.setBackgroundColor(android.graphics.Color.WHITE)
|
||||
val cookieManager = CookieManager.getInstance()
|
||||
cookieManager.setAcceptCookie(true)
|
||||
cookieManager.setAcceptThirdPartyCookies(webView, true)
|
||||
webView.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
@Suppress("DEPRECATION")
|
||||
databaseEnabled = true
|
||||
javaScriptCanOpenWindowsAutomatically = true
|
||||
setSupportMultipleWindows(true)
|
||||
loadWithOverviewMode = true
|
||||
useWideViewPort = true
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
cacheMode = WebSettings.LOAD_CACHE_ELSE_NETWORK
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
userAgentString = MOBILE_USER_AGENT
|
||||
}
|
||||
webView.removeJavascriptInterface("RichDocumentsMobileInterface")
|
||||
webView.addJavascriptInterface(
|
||||
RichDocumentsMobileBridge(this@OfficeEditorActivity) { webViewRef },
|
||||
"RichDocumentsMobileInterface",
|
||||
)
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onCreateWindow(
|
||||
view: WebView?,
|
||||
isDialog: Boolean,
|
||||
isUserGesture: Boolean,
|
||||
resultMsg: Message?,
|
||||
): Boolean {
|
||||
val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false
|
||||
val popup = WebView(webView.context).apply {
|
||||
applyOfficePopupSettings(launch, httpClient, authHosts, interceptPrefixes)
|
||||
}
|
||||
transport.webView = popup
|
||||
resultMsg.sendToTarget()
|
||||
return true
|
||||
}
|
||||
}
|
||||
webView.webViewClient = createClient(launch, httpClient, authHosts, interceptPrefixes)
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun WebView.applyOfficePopupSettings(
|
||||
launch: OfficeEditorLaunch,
|
||||
httpClient: OkHttpClient,
|
||||
authHosts: Set<String>,
|
||||
interceptPrefixes: List<String>,
|
||||
) {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.setSupportMultipleWindows(true)
|
||||
settings.userAgentString = MOBILE_USER_AGENT
|
||||
webViewClient = createClient(launch, httpClient, authHosts, interceptPrefixes)
|
||||
}
|
||||
|
||||
private fun createClient(
|
||||
launch: OfficeEditorLaunch,
|
||||
httpClient: OkHttpClient,
|
||||
authHosts: Set<String>,
|
||||
interceptPrefixes: List<String>,
|
||||
): WebViewClient = OfficeWebViewClient(
|
||||
launch = launch,
|
||||
httpClient = httpClient,
|
||||
authHosts = authHosts,
|
||||
interceptPrefixes = interceptPrefixes,
|
||||
onMainFrameError = { msg ->
|
||||
runOnUiThread {
|
||||
if (!editorVisible) {
|
||||
loadError = "Не удалось загрузить редактор: $msg"
|
||||
}
|
||||
}
|
||||
},
|
||||
onPageReady = { view ->
|
||||
runOnUiThread {
|
||||
view?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
override fun onDestroy() {
|
||||
OfficeWebViewPool.recycle(webViewRef)
|
||||
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_TRUST_ALL_CERTS = "trust_all_certs"
|
||||
private const val EXTRA_SERVER_URL = "server_url"
|
||||
private const val EXTRA_COLLABORA_URL = "collabora_url"
|
||||
|
||||
private const val LOAD_TIMEOUT_MS = 90_000L
|
||||
|
||||
const val MOBILE_USER_AGENT =
|
||||
"Mozilla/5.0 (Linux; Android 13; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||
"Chrome/131.0.0.0 Mobile Safari/537.36"
|
||||
|
||||
const val HIDE_F7CLOUD_CHROME_JS = """
|
||||
(function() {
|
||||
var hide = function(el) { if (el) { el.style.display = 'none'; el.remove(); } };
|
||||
hide(document.getElementById('loadingContainer'));
|
||||
hide(document.getElementById('proxyLoadingContainer'));
|
||||
hide(document.getElementById('header'));
|
||||
hide(document.getElementById('app-navigation'));
|
||||
hide(document.getElementById('app-navigation-vue'));
|
||||
hide(document.querySelector('#body-user'));
|
||||
hide(document.querySelector('footer'));
|
||||
var main = document.getElementById('content');
|
||||
if (main) { main.style.margin = '0'; main.style.padding = '0'; }
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
document.body.style.margin = '0';
|
||||
document.body.style.padding = '0';
|
||||
document.body.style.background = '#fff';
|
||||
var doc = document.getElementById('documents-content');
|
||||
if (doc) {
|
||||
doc.style.position = 'fixed';
|
||||
doc.style.top = '0';
|
||||
doc.style.left = '0';
|
||||
doc.style.right = '0';
|
||||
doc.style.bottom = '0';
|
||||
doc.style.width = '100%';
|
||||
doc.style.height = '100%';
|
||||
doc.style.zIndex = '99999';
|
||||
doc.style.background = '#fff';
|
||||
}
|
||||
var frame = document.getElementById('loleafletframe') || document.querySelector('iframe');
|
||||
if (frame) {
|
||||
frame.style.width = '100%';
|
||||
frame.style.height = '100%';
|
||||
frame.style.minHeight = '100vh';
|
||||
frame.style.border = 'none';
|
||||
}
|
||||
})();
|
||||
"""
|
||||
|
||||
fun intent(context: Context, launch: OfficeEditorLaunch): Intent =
|
||||
Intent(context, OfficeEditorActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
putExtra(EXTRA_URL, launch.url)
|
||||
putExtra(EXTRA_TITLE, launch.title)
|
||||
putExtra(EXTRA_USERNAME, launch.username)
|
||||
putExtra(EXTRA_PASSWORD, launch.password)
|
||||
putExtra(EXTRA_TRUST_ALL_CERTS, launch.trustAllCerts)
|
||||
putExtra(EXTRA_SERVER_URL, launch.serverUrl)
|
||||
putExtra(EXTRA_COLLABORA_URL, launch.collaboraBaseUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package ru.forbion.f7cloud.mobile
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.http.SslError
|
||||
import android.webkit.HttpAuthHandler
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||
import java.io.FilterInputStream
|
||||
import java.net.URI
|
||||
|
||||
internal class OfficeWebViewClient(
|
||||
private val launch: OfficeEditorLaunch,
|
||||
private val httpClient: OkHttpClient,
|
||||
private val authHosts: Set<String>,
|
||||
private val interceptPrefixes: List<String>,
|
||||
private val onMainFrameError: (String) -> Unit,
|
||||
private val onPageReady: (WebView?) -> Unit,
|
||||
) : WebViewClient() {
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
||||
onPageReady(view)
|
||||
}
|
||||
|
||||
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 (!shouldIntercept(url)) return null
|
||||
return runCatching {
|
||||
val builder = Request.Builder().url(url)
|
||||
val method = request.method.uppercase()
|
||||
when (method) {
|
||||
"GET", "HEAD" -> builder.method(method, null)
|
||||
else -> builder.method(method, null)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest,
|
||||
error: android.webkit.WebResourceError?,
|
||||
) {
|
||||
if (request.isForMainFrame) {
|
||||
val msg = error?.description?.toString().orEmpty().ifBlank { "Ошибка сети" }
|
||||
onMainFrameError(msg)
|
||||
}
|
||||
super.onReceivedError(view, request, error)
|
||||
}
|
||||
|
||||
private fun shouldIntercept(url: String): Boolean =
|
||||
interceptPrefixes.any { prefix -> url.startsWith(prefix, ignoreCase = true) }
|
||||
|
||||
companion object {
|
||||
fun buildAuthHosts(launch: OfficeEditorLaunch): Set<String> {
|
||||
val hosts = mutableSetOf<String>()
|
||||
runCatching { URI(launch.url).host }.getOrNull()?.let { hosts += it }
|
||||
runCatching { URI(launch.serverUrl).host }.getOrNull()?.let { hosts += it }
|
||||
if (launch.collaboraBaseUrl.isNotBlank()) {
|
||||
runCatching { URI(launch.collaboraBaseUrl).host }.getOrNull()?.let { hosts += it }
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
fun buildInterceptPrefixes(launch: OfficeEditorLaunch): List<String> {
|
||||
val prefixes = mutableListOf(launch.serverUrl.trimEnd('/'))
|
||||
if (launch.collaboraBaseUrl.isNotBlank()) {
|
||||
prefixes += launch.collaboraBaseUrl.trimEnd('/')
|
||||
}
|
||||
return prefixes.distinct()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package ru.forbion.f7cloud.mobile
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||
|
||||
/**
|
||||
* Держит один настроенный WebView между открытиями редактора (тот же пользователь/сервер).
|
||||
* Статика Collabora подтягивается из HTTP-кэша WebView при повторных loadUrl.
|
||||
*/
|
||||
internal object OfficeWebViewPool {
|
||||
private var webView: WebView? = null
|
||||
private var sessionKey: String? = null
|
||||
private var recycledAtMs: Long = 0L
|
||||
|
||||
private const val MAX_IDLE_MS = 15 * 60 * 1000L
|
||||
|
||||
fun sessionKey(launch: OfficeEditorLaunch): String =
|
||||
"${launch.serverUrl.trimEnd('/')}|${launch.username}"
|
||||
|
||||
fun obtain(
|
||||
context: Context,
|
||||
launch: OfficeEditorLaunch,
|
||||
configure: (WebView) -> Unit,
|
||||
): WebView {
|
||||
evictIfStale()
|
||||
val key = sessionKey(launch)
|
||||
val existing = webView
|
||||
if (existing != null && sessionKey == key) {
|
||||
detach(existing)
|
||||
configure(existing)
|
||||
return existing
|
||||
}
|
||||
dispose()
|
||||
val created = WebView(context).apply { configure(this) }
|
||||
webView = created
|
||||
sessionKey = key
|
||||
recycledAtMs = 0L
|
||||
return created
|
||||
}
|
||||
|
||||
fun recycle(view: WebView?) {
|
||||
if (view == null || view !== webView) return
|
||||
detach(view)
|
||||
view.stopLoading()
|
||||
recycledAtMs = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
sessionKey = null
|
||||
recycledAtMs = 0L
|
||||
}
|
||||
|
||||
fun evictIfStale() {
|
||||
if (webView == null) return
|
||||
if (recycledAtMs > 0L && System.currentTimeMillis() - recycledAtMs > MAX_IDLE_MS) {
|
||||
dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private fun detach(view: WebView) {
|
||||
(view.parent as? ViewGroup)?.removeView(view)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ru.forbion.f7cloud.mobile
|
||||
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
|
||||
class RichDocumentsMobileBridge(
|
||||
private val host: OfficeEditorActivity,
|
||||
private val webViewProvider: () -> WebView?,
|
||||
) {
|
||||
@JavascriptInterface
|
||||
fun documentLoaded() {
|
||||
host.runOnUiThread {
|
||||
host.onCollaboraDocumentLoaded(webViewProvider())
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun close() {
|
||||
host.runOnUiThread { host.finish() }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun close(json: String?) {
|
||||
close()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun share(json: String?) {
|
||||
// Не используется в нативной оболочке
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun insertGraphic(json: String?) {
|
||||
// Не используется
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun fileRename(json: String?) {
|
||||
// Не используется
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun downloadAs(json: String?) {
|
||||
// Не используется
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun paste(json: String?) {
|
||||
// Не используется
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package ru.forbion.f7cloud.mobile.navigation
|
||||
|
||||
import ru.forbion.f7cloud.feature.files.OfficeFileLinks
|
||||
import ru.forbion.f7cloud.feature.talk.TalkDeepLink
|
||||
import ru.forbion.f7cloud.mobile.ui.AppTab
|
||||
|
||||
/**
|
||||
* Maps F7cloud web URLs (dashboard widgets, push, notifications) to in-app navigation.
|
||||
*/
|
||||
object AppLinkResolver {
|
||||
fun resolve(url: String?, serverUrl: String = ""): AppLinkTarget? {
|
||||
if (url.isNullOrBlank()) return null
|
||||
val lower = url.lowercase()
|
||||
|
||||
TalkDeepLink.extractRoomToken(url)?.let { token ->
|
||||
return AppLinkTarget(
|
||||
tab = AppTab.Talk,
|
||||
talkRoomToken = token,
|
||||
talkMessageId = TalkDeepLink.extractMessageId(url),
|
||||
)
|
||||
}
|
||||
|
||||
parseMail(url)?.let { return it }
|
||||
|
||||
parseCalendar(url)?.let { return it }
|
||||
|
||||
parseDeck(url)?.let { return it }
|
||||
|
||||
parseTasks(url)?.let { return it }
|
||||
|
||||
OfficeFileLinks.parseFileId(url, serverUrl)?.let { fileId ->
|
||||
return AppLinkTarget(tab = AppTab.Files, fileId = fileId)
|
||||
}
|
||||
|
||||
if (lower.contains("/apps/files") || lower.contains("/remote.php/dav/files") ||
|
||||
lower.contains("fileid=") || lower.contains("/f/")
|
||||
) {
|
||||
val fileId = OfficeFileLinks.parseFileId(url, serverUrl)
|
||||
?: Regex("""fileid=(\d+)""", RegexOption.IGNORE_CASE).find(url)
|
||||
?.groupValues?.get(1)?.toLongOrNull()
|
||||
return AppLinkTarget(tab = AppTab.Files, fileId = fileId)
|
||||
}
|
||||
|
||||
return when {
|
||||
lower.contains("/apps/f7mail") || lower.contains("/apps/mail") -> AppLinkTarget(tab = AppTab.Mail)
|
||||
lower.contains("/apps/calendar") -> AppLinkTarget(tab = AppTab.Calendar)
|
||||
lower.contains("/apps/tasks") -> parseTasks(url) ?: AppLinkTarget(tab = AppTab.Tasks)
|
||||
lower.contains("/apps/deck") -> AppLinkTarget(tab = AppTab.Deck)
|
||||
lower.contains("/apps/spreed") -> AppLinkTarget(tab = AppTab.Talk)
|
||||
lower.contains("/apps/f7support") -> {
|
||||
AppLinkTarget(tab = AppTab.Support, supportTicket = extractSupportTicket(url))
|
||||
}
|
||||
lower.contains("/apps/dashboard") -> AppLinkTarget(tab = AppTab.Files)
|
||||
lower.contains("/apps/contacts") -> AppLinkTarget(tab = AppTab.Contacts)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMail(url: String): AppLinkTarget? {
|
||||
if (!url.contains("/apps/f7mail", ignoreCase = true) &&
|
||||
!url.contains("/apps/mail", ignoreCase = true)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val thread = Regex("""/thread/(\d+)""", RegexOption.IGNORE_CASE).find(url)
|
||||
?.groupValues?.get(1)?.toIntOrNull()
|
||||
val mailbox = Regex("""/box/(\d+)""", RegexOption.IGNORE_CASE).find(url)
|
||||
?.groupValues?.get(1)?.toIntOrNull()
|
||||
return AppLinkTarget(
|
||||
tab = AppTab.Mail,
|
||||
mailMessageId = thread,
|
||||
mailMailboxId = mailbox,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseCalendar(url: String): AppLinkTarget? {
|
||||
if (!url.contains("/apps/calendar", ignoreCase = true)) return null
|
||||
val uid = Regex("""/edit/([^/?#]+)""", RegexOption.IGNORE_CASE).find(url)
|
||||
?.groupValues?.get(1)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
return AppLinkTarget(tab = AppTab.Calendar, calendarEventUid = uid)
|
||||
}
|
||||
|
||||
private fun parseDeck(url: String): AppLinkTarget? {
|
||||
if (!url.contains("/apps/deck", ignoreCase = true)) return null
|
||||
val cardId = Regex("""/card/(\d+)""", RegexOption.IGNORE_CASE).find(url)
|
||||
?.groupValues?.get(1)?.toIntOrNull()
|
||||
return AppLinkTarget(tab = AppTab.Deck, deckCardId = cardId)
|
||||
}
|
||||
|
||||
private fun parseTasks(url: String): AppLinkTarget? {
|
||||
if (!url.contains("/apps/tasks", ignoreCase = true)) return null
|
||||
val slug = Regex("""/calendars/([^/?#]+)""", RegexOption.IGNORE_CASE).find(url)
|
||||
?.groupValues?.get(1)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
return AppLinkTarget(tab = AppTab.Tasks, tasksListSlug = slug)
|
||||
}
|
||||
|
||||
private fun extractSupportTicket(url: String): String? {
|
||||
val marker = "ticket="
|
||||
val idx = url.indexOf(marker, ignoreCase = true)
|
||||
if (idx < 0) return null
|
||||
return url.substring(idx + marker.length).takeWhile { it.isDigit() }.ifBlank { null }
|
||||
}
|
||||
}
|
||||
|
||||
data class AppLinkTarget(
|
||||
val tab: AppTab,
|
||||
val talkRoomToken: String? = null,
|
||||
val talkMessageId: Long? = null,
|
||||
val mailMessageId: Int? = null,
|
||||
val mailMailboxId: Int? = null,
|
||||
val calendarEventUid: String? = null,
|
||||
val deckCardId: Int? = null,
|
||||
val fileId: Long? = null,
|
||||
val supportTicket: String? = null,
|
||||
val tasksListSlug: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
package ru.forbion.f7cloud.mobile.permissions
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
object F7AppPermissions {
|
||||
|
||||
/** Все runtime-разрешения, которые нужны приложению после входа. */
|
||||
fun requiredRuntimePermissions(): List<String> = buildList {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
add(Manifest.permission.READ_MEDIA_IMAGES)
|
||||
add(Manifest.permission.READ_MEDIA_VIDEO)
|
||||
add(Manifest.permission.READ_MEDIA_AUDIO)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
add(Manifest.permission.CAMERA)
|
||||
add(Manifest.permission.RECORD_AUDIO)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
add(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
}
|
||||
}
|
||||
|
||||
fun missing(context: Context): List<String> =
|
||||
requiredRuntimePermissions().filter {
|
||||
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun hasAll(context: Context): Boolean = missing(context).isEmpty()
|
||||
|
||||
fun talkCallPermissions(): Array<String> = arrayOf(
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
)
|
||||
|
||||
fun missingTalkCall(context: Context): List<String> =
|
||||
talkCallPermissions().filter {
|
||||
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ru.forbion.f7cloud.mobile.permissions
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
|
||||
@Composable
|
||||
fun F7PermissionRationaleDialog(
|
||||
visible: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
if (!visible) return
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = "Разрешения для F7cloud",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = "Чтобы работали уведомления о звонках и сообщениях, видеозвонки в конференциях " +
|
||||
"и загрузка файлов, разрешите доступ к уведомлениям, камере, микрофону и хранилищу.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text("Разрешить", color = F7Colors.Primary)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Позже", color = F7Colors.TextSecondary)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package ru.forbion.f7cloud.mobile.qr
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.camera.core.Camera
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
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.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
||||
import ru.forbion.f7cloud.mobile.R
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class F7QrScannerActivity : ComponentActivity() {
|
||||
|
||||
private val finishing = AtomicBoolean(false)
|
||||
private val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
F7Theme {
|
||||
QrScannerScreen(
|
||||
onClose = { finish() },
|
||||
onStableResult = ::finishWithStableResult,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
analysisExecutor.shutdown()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun finishWithStableResult(payload: String) {
|
||||
if (!finishing.compareAndSet(false, true)) return
|
||||
setResult(
|
||||
RESULT_OK,
|
||||
Intent().putExtra(RESULT_EXTRA, payload),
|
||||
)
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val RESULT_EXTRA = "ru.forbion.f7cloud.mobile.qr_scan_result"
|
||||
private const val REQUIRED_STABLE_READS = 5
|
||||
|
||||
fun intent(context: Context): Intent =
|
||||
Intent(context, F7QrScannerActivity::class.java)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun QrScannerScreen(
|
||||
onClose: () -> Unit,
|
||||
onStableResult: (String) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val idleHint = stringResource(R.string.qr_scan_hint_idle)
|
||||
var hint by remember(idleHint) { mutableStateOf(idleHint) }
|
||||
var stableCount by remember { mutableIntStateOf(0) }
|
||||
var torchEnabled by remember { mutableStateOf(false) }
|
||||
var camera by remember { mutableStateOf<Camera?>(null) }
|
||||
val previewView = remember { PreviewView(context).apply { implementationMode = PreviewView.ImplementationMode.COMPATIBLE } }
|
||||
|
||||
val reader = remember {
|
||||
MultiFormatReader().apply {
|
||||
setHints(
|
||||
mapOf(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
DecodeHintType.CHARACTER_SET to "UTF-8",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val mainExecutor = remember { ContextCompat.getMainExecutor(context) }
|
||||
var lastPayload by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun onDecode(text: String?) {
|
||||
if (finishing.get()) return
|
||||
if (text.isNullOrBlank()) {
|
||||
lastPayload = null
|
||||
stableCount = 0
|
||||
hint = context.getString(R.string.qr_scan_hint_idle)
|
||||
return
|
||||
}
|
||||
val trimmed = text.trim()
|
||||
if (!LoginFlowClient.isCompleteQrPayload(trimmed)) {
|
||||
lastPayload = null
|
||||
stableCount = 0
|
||||
hint = context.getString(R.string.qr_scan_hint_align)
|
||||
return
|
||||
}
|
||||
if (trimmed == lastPayload) {
|
||||
stableCount += 1
|
||||
} else {
|
||||
lastPayload = trimmed
|
||||
stableCount = 1
|
||||
}
|
||||
hint = if (stableCount >= REQUIRED_STABLE_READS) {
|
||||
context.getString(R.string.qr_scan_hint_done)
|
||||
} else {
|
||||
context.getString(R.string.qr_scan_hint_progress, stableCount, REQUIRED_STABLE_READS)
|
||||
}
|
||||
if (stableCount >= REQUIRED_STABLE_READS) {
|
||||
onStableResult(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(previewView) {
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
cameraProviderFuture.addListener({
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.surfaceProvider = previewView.surfaceProvider
|
||||
}
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
analysis.setAnalyzer(analysisExecutor) { imageProxy ->
|
||||
val decoded = decodeQr(reader, imageProxy)
|
||||
imageProxy.close()
|
||||
mainExecutor.execute { onDecode(decoded) }
|
||||
}
|
||||
runCatching {
|
||||
cameraProvider.unbindAll()
|
||||
camera = cameraProvider.bindToLifecycle(
|
||||
this@F7QrScannerActivity,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
analysis,
|
||||
)
|
||||
}
|
||||
}, mainExecutor)
|
||||
onDispose {
|
||||
runCatching { cameraProviderFuture.get().unbindAll() }
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.qr_scan_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.qr_scan_close),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
val next = !torchEnabled
|
||||
camera?.cameraControl?.enableTorch(next)
|
||||
torchEnabled = next
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = if (torchEnabled) {
|
||||
stringResource(R.string.qr_scan_torch_off)
|
||||
} else {
|
||||
stringResource(R.string.qr_scan_torch_on)
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.55f),
|
||||
titleContentColor = Color.White,
|
||||
navigationIconContentColor = Color.White,
|
||||
actionIconContentColor = Color.White,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = Color.Black,
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { previewView },
|
||||
)
|
||||
|
||||
QrFinderOverlay(modifier = Modifier.fillMaxSize())
|
||||
|
||||
Text(
|
||||
text = hint,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QrFinderOverlay(modifier: Modifier = Modifier) {
|
||||
val frameColor = F7Colors.Primary
|
||||
Canvas(modifier = modifier) {
|
||||
val frameSize = minOf(size.width, size.height) * 0.68f
|
||||
val left = (size.width - frameSize) / 2f
|
||||
val top = (size.height - frameSize) / 2f
|
||||
val frameRect = Rect(Offset(left, top), Size(frameSize, frameSize))
|
||||
|
||||
val overlayPath = Path().apply {
|
||||
fillType = PathFillType.EvenOdd
|
||||
addRect(Rect(Offset.Zero, size))
|
||||
addRoundRect(RoundRect(frameRect, CornerRadius(16f, 16f)))
|
||||
}
|
||||
drawPath(
|
||||
path = overlayPath,
|
||||
color = Color.Black.copy(alpha = 0.55f),
|
||||
)
|
||||
drawRoundRect(
|
||||
color = frameColor,
|
||||
topLeft = frameRect.topLeft,
|
||||
size = frameRect.size,
|
||||
cornerRadius = CornerRadius(16f, 16f),
|
||||
style = Stroke(width = 4f),
|
||||
blendMode = BlendMode.SrcOver,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeQr(reader: MultiFormatReader, imageProxy: ImageProxy): String? {
|
||||
if (imageProxy.format != android.graphics.ImageFormat.YUV_420_888) return null
|
||||
val yBuffer = imageProxy.planes[0].buffer
|
||||
val ySize = yBuffer.remaining()
|
||||
val yuv = ByteArray(ySize)
|
||||
yBuffer.get(yuv)
|
||||
|
||||
val width = imageProxy.width
|
||||
val height = imageProxy.height
|
||||
val source = com.google.zxing.PlanarYUVLuminanceSource(
|
||||
yuv,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
false,
|
||||
)
|
||||
return runCatching {
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
|
||||
}.getOrNull()?.also {
|
||||
reader.reset()
|
||||
} ?: run {
|
||||
reader.reset()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import ru.forbion.f7cloud.core.auth.AppLockStore
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||
import ru.forbion.f7cloud.mobile.R
|
||||
|
||||
private enum class AppLockSetupStage {
|
||||
Choose,
|
||||
PinCreate,
|
||||
PinConfirm,
|
||||
}
|
||||
|
||||
private const val PIN_LENGTH = 4
|
||||
|
||||
private fun biometricAuthenticators(): Int =
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
|
||||
private fun canUseBiometric(context: android.content.Context): Boolean =
|
||||
BiometricManager.from(context).canAuthenticate(biometricAuthenticators()) ==
|
||||
BiometricManager.BIOMETRIC_SUCCESS
|
||||
|
||||
@Composable
|
||||
fun AppLockGate(
|
||||
lockStore: AppLockStore,
|
||||
unlockNonce: Int = 0,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (!lockStore.isEnabled()) {
|
||||
content()
|
||||
return
|
||||
}
|
||||
var unlocked by remember { mutableStateOf(false) }
|
||||
var lockSession by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (lockStore.consumeColdStart()) {
|
||||
unlocked = false
|
||||
lockSession++
|
||||
}
|
||||
}
|
||||
|
||||
val processLifecycle = ProcessLifecycleOwner.get()
|
||||
DisposableEffect(processLifecycle, lockStore) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP -> lockStore.markBackgrounded()
|
||||
Lifecycle.Event.ON_START -> {
|
||||
when {
|
||||
lockStore.consumeColdStart() -> {
|
||||
unlocked = false
|
||||
lockSession++
|
||||
}
|
||||
lockStore.shouldRequireUnlock() -> {
|
||||
unlocked = false
|
||||
lockSession++
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
processLifecycle.lifecycle.addObserver(observer)
|
||||
onDispose { processLifecycle.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
LaunchedEffect(unlockNonce) {
|
||||
if (unlockNonce > 0) {
|
||||
unlocked = true
|
||||
lockStore.clearBackgroundMarker()
|
||||
}
|
||||
}
|
||||
|
||||
if (unlocked) {
|
||||
content()
|
||||
return
|
||||
}
|
||||
AppLockUnlockScreen(
|
||||
lockStore = lockStore,
|
||||
lockSession = lockSession,
|
||||
onUnlocked = {
|
||||
lockStore.clearBackgroundMarker()
|
||||
unlocked = true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppLockSetupDialog(
|
||||
visible: Boolean,
|
||||
lockStore: AppLockStore,
|
||||
onDismiss: () -> Unit,
|
||||
onLockConfigured: () -> Unit = {},
|
||||
) {
|
||||
if (!visible) return
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context.findFragmentActivity()
|
||||
var stage by remember { mutableStateOf(AppLockSetupStage.Choose) }
|
||||
var firstPin by remember { mutableStateOf("") }
|
||||
var currentPin by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val biometricAvailable = remember { canUseBiometric(context) }
|
||||
|
||||
fun finishSkip() {
|
||||
lockStore.markSetupOffered()
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
fun finishPinSetup(pin: String) {
|
||||
lockStore.enable(pin, biometric = false)
|
||||
lockStore.markSetupOffered()
|
||||
onLockConfigured()
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
fun finishBiometricSetup() {
|
||||
lockStore.enableBiometricOnly()
|
||||
lockStore.markSetupOffered()
|
||||
onLockConfigured()
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
fun launchBiometricSetup() {
|
||||
val host = activity ?: run {
|
||||
error = "Биометрия недоступна на этом устройстве"
|
||||
return
|
||||
}
|
||||
val executor = ContextCompat.getMainExecutor(context)
|
||||
val prompt = BiometricPrompt(
|
||||
host,
|
||||
executor,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
finishBiometricSetup()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
if (errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON
|
||||
) {
|
||||
error = errString.toString()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
prompt.authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Биометрия")
|
||||
.setSubtitle("Подтвердите отпечаток пальца для защиты приложения")
|
||||
.setNegativeButtonText("Отмена")
|
||||
.setAllowedAuthenticators(biometricAuthenticators())
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun appendDigit(digit: String) {
|
||||
if (currentPin.length >= PIN_LENGTH) return
|
||||
currentPin += digit
|
||||
error = null
|
||||
if (currentPin.length == PIN_LENGTH) {
|
||||
when (stage) {
|
||||
AppLockSetupStage.PinCreate -> {
|
||||
firstPin = currentPin
|
||||
currentPin = ""
|
||||
stage = AppLockSetupStage.PinConfirm
|
||||
}
|
||||
AppLockSetupStage.PinConfirm -> {
|
||||
if (currentPin == firstPin) {
|
||||
finishPinSetup(currentPin)
|
||||
} else {
|
||||
error = "PIN-коды не совпадают"
|
||||
currentPin = ""
|
||||
firstPin = ""
|
||||
stage = AppLockSetupStage.PinCreate
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeDigit() {
|
||||
if (currentPin.isNotEmpty()) {
|
||||
currentPin = currentPin.dropLast(1)
|
||||
error = null
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = {},
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = false,
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color.White,
|
||||
) {
|
||||
when (stage) {
|
||||
AppLockSetupStage.Choose -> AppLockSetupChoiceContent(
|
||||
canUseBiometric = biometricAvailable,
|
||||
error = error,
|
||||
onChoosePin = {
|
||||
error = null
|
||||
currentPin = ""
|
||||
firstPin = ""
|
||||
stage = AppLockSetupStage.PinCreate
|
||||
},
|
||||
onChooseBiometric = {
|
||||
error = null
|
||||
launchBiometricSetup()
|
||||
},
|
||||
onSkip = ::finishSkip,
|
||||
)
|
||||
AppLockSetupStage.PinCreate,
|
||||
AppLockSetupStage.PinConfirm,
|
||||
-> AppLockPinKeypadContent(
|
||||
title = if (stage == AppLockSetupStage.PinCreate) {
|
||||
"Придумайте PIN-код"
|
||||
} else {
|
||||
"Повторите PIN-код"
|
||||
},
|
||||
subtitle = if (stage == AppLockSetupStage.PinCreate) {
|
||||
"Введите $PIN_LENGTH цифры на клавиатуре ниже"
|
||||
} else {
|
||||
"Подтвердите PIN-код ещё раз"
|
||||
},
|
||||
pinLength = currentPin.length,
|
||||
maxPinLength = PIN_LENGTH,
|
||||
error = error,
|
||||
onDigit = ::appendDigit,
|
||||
onBackspace = ::removeDigit,
|
||||
onBack = {
|
||||
error = null
|
||||
currentPin = ""
|
||||
firstPin = ""
|
||||
stage = AppLockSetupStage.Choose
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppLockSetupChoiceContent(
|
||||
canUseBiometric: Boolean,
|
||||
error: String?,
|
||||
onChoosePin: () -> Unit,
|
||||
onChooseBiometric: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Защита приложения",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = "Вы хотите использовать PIN-код или отпечаток пальца?",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = F7Colors.TextSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
if (!error.isNullOrBlank()) {
|
||||
Text(
|
||||
text = error,
|
||||
color = F7Colors.Error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
F7PrimaryButton(
|
||||
text = "PIN-код",
|
||||
onClick = onChoosePin,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (canUseBiometric) {
|
||||
F7PrimaryButton(
|
||||
text = "Отпечаток пальца",
|
||||
onClick = onChooseBiometric,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
F7TextButton(
|
||||
text = "Пропустить",
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppLockPinKeypadContent(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
pinLength: Int,
|
||||
maxPinLength: Int,
|
||||
error: String?,
|
||||
onDigit: (String) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
onBack: (() -> Unit)? = null,
|
||||
extraAction: (@Composable () -> Unit)? = null,
|
||||
header: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (onBack != null) {
|
||||
F7TextButton(
|
||||
text = "← Назад",
|
||||
onClick = onBack,
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
)
|
||||
}
|
||||
header?.invoke()
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
|
||||
)
|
||||
PinDots(
|
||||
filledCount = pinLength,
|
||||
totalCount = maxPinLength,
|
||||
)
|
||||
if (!error.isNullOrBlank()) {
|
||||
Text(
|
||||
text = error,
|
||||
color = F7Colors.Error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
PinNumericKeypad(
|
||||
onDigit = onDigit,
|
||||
onBackspace = onBackspace,
|
||||
)
|
||||
extraAction?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinDots(filledCount: Int, totalCount: Int) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
repeat(totalCount) { index ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (index < filledCount) {
|
||||
F7Colors.Primary
|
||||
} else {
|
||||
Color(0xFFE6E6E6)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinNumericKeypad(
|
||||
onDigit: (String) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
) {
|
||||
val rows = listOf(
|
||||
listOf("1", "2", "3"),
|
||||
listOf("4", "5", "6"),
|
||||
listOf("7", "8", "9"),
|
||||
listOf("", "0", "⌫"),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
rows.forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
row.forEach { key ->
|
||||
when (key) {
|
||||
"" -> Spacer(modifier = Modifier.size(72.dp))
|
||||
"⌫" -> PinKey(
|
||||
label = "⌫",
|
||||
onClick = onBackspace,
|
||||
)
|
||||
else -> PinKey(
|
||||
label = key,
|
||||
onClick = { onDigit(key) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinKey(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFFF3F3F3))
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = if (label == "⌫") 24.sp else 28.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppLockBrandedBackdrop(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White)
|
||||
.padding(horizontal = 32.dp, vertical = 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.f7_app_lock_logo),
|
||||
contentDescription = "F7cloud",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.88f)
|
||||
.padding(bottom = 28.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppLockUnlockScreen(
|
||||
lockStore: AppLockStore,
|
||||
lockSession: Int,
|
||||
onUnlocked: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context.findFragmentActivity()
|
||||
var pin by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var showPinEntry by remember(lockSession) { mutableStateOf(!lockStore.useBiometric()) }
|
||||
val biometricOnly = lockStore.isBiometricOnly()
|
||||
val biometricAvailable = remember { canUseBiometric(context) }
|
||||
|
||||
fun verifyPinInput() {
|
||||
if (lockStore.verifyPin(pin)) {
|
||||
onUnlocked()
|
||||
} else {
|
||||
error = "Неверный PIN"
|
||||
pin = ""
|
||||
}
|
||||
}
|
||||
|
||||
fun appendDigit(digit: String) {
|
||||
if (pin.length >= PIN_LENGTH) return
|
||||
pin += digit
|
||||
error = null
|
||||
if (pin.length == PIN_LENGTH) {
|
||||
verifyPinInput()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeDigit() {
|
||||
if (pin.isNotEmpty()) {
|
||||
pin = pin.dropLast(1)
|
||||
error = null
|
||||
}
|
||||
}
|
||||
|
||||
fun launchBiometricUnlock() {
|
||||
val host = activity ?: run {
|
||||
error = "Биометрия недоступна"
|
||||
return
|
||||
}
|
||||
val executor = ContextCompat.getMainExecutor(context)
|
||||
val prompt = BiometricPrompt(
|
||||
host,
|
||||
executor,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
onUnlocked()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
when (errorCode) {
|
||||
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
|
||||
if (!biometricOnly) {
|
||||
showPinEntry = true
|
||||
}
|
||||
}
|
||||
BiometricPrompt.ERROR_USER_CANCELED -> Unit
|
||||
else -> error = errString.toString()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
prompt.authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Разблокировка F7cloud")
|
||||
.setSubtitle("Прикоснитесь к сканеру отпечатка")
|
||||
.setAllowedAuthenticators(biometricAuthenticators())
|
||||
.setNegativeButtonText(if (biometricOnly) "Отмена" else "Ввести PIN")
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(lockSession, lockStore.useBiometric(), activity) {
|
||||
if (lockStore.useBiometric() && biometricAvailable && activity != null && !showPinEntry) {
|
||||
launchBiometricUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
if (showPinEntry && !biometricOnly) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
AppLockPinKeypadContent(
|
||||
title = "Введите PIN-код",
|
||||
subtitle = "Для доступа к приложению",
|
||||
pinLength = pin.length,
|
||||
maxPinLength = PIN_LENGTH,
|
||||
error = error,
|
||||
onDigit = ::appendDigit,
|
||||
onBackspace = ::removeDigit,
|
||||
onBack = if (lockStore.useBiometric() && biometricAvailable) {
|
||||
{
|
||||
error = null
|
||||
pin = ""
|
||||
showPinEntry = false
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
header = {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.f7_app_lock_logo),
|
||||
contentDescription = "F7cloud",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.padding(bottom = 16.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
AppLockBrandedBackdrop {
|
||||
if (lockStore.useBiometric() && biometricAvailable) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Fingerprint,
|
||||
contentDescription = null,
|
||||
tint = F7Colors.Primary,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Прикоснитесь к сканеру отпечатка",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = F7Colors.TextSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
)
|
||||
if (!error.isNullOrBlank()) {
|
||||
Text(
|
||||
text = error ?: "",
|
||||
color = F7Colors.Error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
F7PrimaryButton(
|
||||
text = "Сканер отпечатка",
|
||||
onClick = ::launchBiometricUnlock,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp),
|
||||
)
|
||||
}
|
||||
if (!biometricOnly && lockStore.useBiometric() && biometricAvailable) {
|
||||
F7TextButton(
|
||||
text = "Ввести PIN",
|
||||
onClick = { showPinEntry = true },
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun android.content.Context.findFragmentActivity(): FragmentActivity? {
|
||||
var ctx: android.content.Context = this
|
||||
while (true) {
|
||||
when (ctx) {
|
||||
is FragmentActivity -> return ctx
|
||||
is android.content.ContextWrapper -> ctx = ctx.baseContext
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import okhttp3.Request
|
||||
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
|
||||
import ru.forbion.f7cloud.core.network.applyOcsJson
|
||||
|
||||
object AppMenuRepository {
|
||||
fun fetchExternalSites(session: AuthSession): List<AppMenuExternalSite> {
|
||||
val client = NetworkFactory.newAuthedClient(
|
||||
session.username,
|
||||
session.appPassword,
|
||||
session.trustAllCerts,
|
||||
)
|
||||
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/external/api/v1?format=json"
|
||||
val request = Request.Builder().url(url).applyOcsJson().get().build()
|
||||
return runCatching {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
return emptyList()
|
||||
}
|
||||
val body = response.body!!.string()
|
||||
val array = when {
|
||||
body.trimStart().startsWith("[") -> JSONArray(body)
|
||||
else -> {
|
||||
val data = JSONObject(body).optJSONObject("ocs")?.opt("data")
|
||||
when (data) {
|
||||
is JSONArray -> data
|
||||
else -> JSONArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
(0 until array.length()).mapNotNull { index ->
|
||||
val site = array.optJSONObject(index) ?: return@mapNotNull null
|
||||
val name = site.optString("name").ifBlank { return@mapNotNull null }
|
||||
val icon = site.optString("icon").ifBlank { null }
|
||||
val redirect = site.optInt("redirect", 0) == 1
|
||||
val rawUrl = site.optString("url")
|
||||
val siteId = site.optInt("id", -1)
|
||||
val openUrl = when {
|
||||
redirect && rawUrl.isNotBlank() -> rawUrl
|
||||
siteId >= 0 -> "${session.serverUrl.trimEnd('/')}/index.php/apps/external/$siteId/"
|
||||
rawUrl.isNotBlank() -> rawUrl
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
AppMenuExternalSite(
|
||||
name = name,
|
||||
iconUrl = icon ?: defaultExternalIcon(session.serverUrl, name),
|
||||
openUrl = openUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun defaultExternalIcon(serverUrl: String, name: String): String {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
return when {
|
||||
name.contains("bitrix", ignoreCase = true) -> "$base/themes/forbion/images/header/bitrix-glass.svg"
|
||||
name.contains("1c", ignoreCase = true) -> "$base/themes/forbion/images/header/1c-glass.svg"
|
||||
else -> "$base/index.php/apps/external/img/external.svg"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
|
||||
|
||||
enum class AppTab(
|
||||
val title: String,
|
||||
val headerIconPath: String,
|
||||
val menuIconPath: String,
|
||||
) {
|
||||
Mail("Почта", "mail-header-icon.svg", "mail-glass.svg"),
|
||||
Files("Файлы", "files-header-icon.svg", "files-glass.svg"),
|
||||
Calendar("Календарь", "calendar-header-icon.svg", "calendar-glass.svg"),
|
||||
Contacts("Контакты", "contacts-header-icon.svg", "contact-glass.svg"),
|
||||
Talk("Конференции", "spreed-header-icon.svg", "spreed-glass.svg"),
|
||||
Deck("Карточки", "deck-header-icon.svg", "deck-glass.svg"),
|
||||
Tasks("Задачи", "task-header-icon.svg", "task-glass.svg"),
|
||||
Support("Поддержка", "icon-header-f7support.svg", "icon-header-f7support.svg"),
|
||||
;
|
||||
|
||||
fun menuIconUrl(serverUrl: String): String {
|
||||
return "${serverUrl.trimEnd('/')}/themes/forbion/images/header/$menuIconPath"
|
||||
}
|
||||
}
|
||||
|
||||
private data class AppMenuEntry(
|
||||
val tab: AppTab?,
|
||||
val label: String,
|
||||
val iconPath: String,
|
||||
val webPath: String? = null,
|
||||
)
|
||||
|
||||
private val coreAppMenuEntries = listOf(
|
||||
AppMenuEntry(AppTab.Mail, "Почта", "mail-glass.svg"),
|
||||
AppMenuEntry(AppTab.Files, "Файлы", "files-glass.svg"),
|
||||
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar-glass.svg"),
|
||||
AppMenuEntry(AppTab.Contacts, "Контакты", "contact-glass.svg"),
|
||||
AppMenuEntry(AppTab.Talk, "Конференции", "spreed-glass.svg"),
|
||||
AppMenuEntry(AppTab.Deck, "Карточки", "deck-glass.svg"),
|
||||
AppMenuEntry(AppTab.Tasks, "Задачи", "task-glass.svg"),
|
||||
AppMenuEntry(null, "Заметки", "notes-glass.svg", webPath = "/apps/notes/"),
|
||||
AppMenuEntry(AppTab.Support, "Поддержка", "icon-header-f7support.svg"),
|
||||
)
|
||||
|
||||
data class AppMenuExternalSite(
|
||||
val name: String,
|
||||
val iconUrl: String,
|
||||
val openUrl: String,
|
||||
)
|
||||
|
||||
val appMenuTabs: List<AppTab> = AppTab.entries
|
||||
|
||||
fun appMenuItems(
|
||||
serverUrl: String,
|
||||
active: AppTab,
|
||||
externalSites: List<AppMenuExternalSite> = emptyList(),
|
||||
): List<F7AppMenuItem> {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
val core = coreAppMenuEntries.map { entry ->
|
||||
F7AppMenuItem(
|
||||
label = entry.label,
|
||||
iconUrl = "$base/themes/forbion/images/header/${entry.iconPath}",
|
||||
selected = entry.tab == active,
|
||||
externalUrl = entry.webPath?.let { "$base$it" },
|
||||
)
|
||||
}
|
||||
val external = externalSites.map { site ->
|
||||
F7AppMenuItem(
|
||||
label = site.name,
|
||||
iconUrl = site.iconUrl,
|
||||
selected = false,
|
||||
externalUrl = site.openUrl,
|
||||
)
|
||||
}
|
||||
return core + external
|
||||
}
|
||||
|
||||
fun appTabFromMenuIndex(index: Int): AppTab? {
|
||||
return coreAppMenuEntries.getOrNull(index)?.tab
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import ru.forbion.f7cloud.core.auth.AppLockStore
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
import ru.forbion.f7cloud.core.auth.AuthVerifier
|
||||
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
|
||||
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
||||
import ru.forbion.f7cloud.core.push.F7PushEvent
|
||||
import ru.forbion.f7cloud.core.push.F7PushEventHub
|
||||
import ru.forbion.f7cloud.core.push.F7PushRegistrar
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OverlayNavigationProvider
|
||||
import ru.forbion.f7cloud.core.designsystem.F7AppMenuSheet
|
||||
import ru.forbion.f7cloud.core.designsystem.F7AppScaffold
|
||||
import ru.forbion.f7cloud.core.designsystem.F7AutoHideBottomBar
|
||||
import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions
|
||||
import ru.forbion.f7cloud.core.designsystem.F7BottomBarConfig
|
||||
import ru.forbion.f7cloud.core.designsystem.F7MobileBottomBar
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
|
||||
import ru.forbion.f7cloud.mobile.OfficeEditorActivity
|
||||
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
|
||||
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
|
||||
import ru.forbion.f7cloud.feature.files.OfficeWarmup
|
||||
import ru.forbion.f7cloud.mobile.permissions.F7AppPermissions
|
||||
import ru.forbion.f7cloud.mobile.permissions.F7PermissionRationaleDialog
|
||||
import ru.forbion.f7cloud.feature.calendar.CalendarScreen
|
||||
import ru.forbion.f7cloud.feature.contacts.ContactsScreen
|
||||
import ru.forbion.f7cloud.feature.deck.DeckScreen
|
||||
import ru.forbion.f7cloud.feature.files.FilesScreen
|
||||
import ru.forbion.f7cloud.feature.f7support.SupportScreen
|
||||
import ru.forbion.f7cloud.feature.mail.MailScreen
|
||||
import ru.forbion.f7cloud.feature.tasks.TasksScreen
|
||||
import ru.forbion.f7cloud.feature.talk.TalkScreen
|
||||
import ru.forbion.f7cloud.mobile.navigation.AppLinkResolver
|
||||
import ru.forbion.f7cloud.mobile.navigation.AppLinkTarget
|
||||
|
||||
@Composable
|
||||
fun AppScaffold(
|
||||
openUrl: String? = null,
|
||||
onOpenUrlConsumed: () -> Unit = {},
|
||||
onRequestRuntimePermissions: (onFinished: (() -> Unit)?) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val authStore = remember { AuthStore(context) }
|
||||
var session by remember { mutableStateOf(authStore.load()) }
|
||||
var activeTab by rememberSaveable(
|
||||
saver = Saver(
|
||||
save = { state -> state.value.name },
|
||||
restore = { name ->
|
||||
mutableStateOf(
|
||||
if (name == "Widgets") AppTab.Files
|
||||
else runCatching { AppTab.valueOf(name) }.getOrDefault(AppTab.Files),
|
||||
)
|
||||
},
|
||||
),
|
||||
) { mutableStateOf(AppTab.Files) }
|
||||
var pendingOpenUrl by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingTalkRoomToken by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingTalkMessageId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
var pendingSupportTicket by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingMailMessageId by rememberSaveable { mutableIntStateOf(-1) }
|
||||
var pendingMailMailboxId by rememberSaveable { mutableIntStateOf(-1) }
|
||||
var pendingCalendarEventUid by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingDeckCardId by rememberSaveable { mutableIntStateOf(-1) }
|
||||
var pendingTasksListSlug by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingFileId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
var tabHistory by rememberSaveable { mutableStateOf(emptyList<String>()) }
|
||||
var menuOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var talkInRoom by rememberSaveable { mutableStateOf(false) }
|
||||
var mailInMessage by rememberSaveable { mutableStateOf(false) }
|
||||
var bottomBarActivity by remember { mutableIntStateOf(0) }
|
||||
var lastBottomBarPulse by remember { mutableLongStateOf(0L) }
|
||||
var profileOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var notificationsOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var mailSidebarOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var calendarSidebarOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var filesSidebarOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var mailSettingsOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var filesSettingsOpen by rememberSaveable { mutableStateOf(false) }
|
||||
val bottomBarPinned = menuOpen || profileOpen || notificationsOpen ||
|
||||
(activeTab == AppTab.Mail && (mailSidebarOpen || mailSettingsOpen)) ||
|
||||
(activeTab == AppTab.Calendar && calendarSidebarOpen) ||
|
||||
(activeTab == AppTab.Files && (filesSidebarOpen || filesSettingsOpen))
|
||||
var filesUploadRequest by remember { mutableIntStateOf(0) }
|
||||
var contactsCreateRequest by remember { mutableIntStateOf(0) }
|
||||
var tasksCreateRequest by remember { mutableIntStateOf(0) }
|
||||
var supportCreateRequest by remember { mutableIntStateOf(0) }
|
||||
var calendarSettingsRequest by remember { mutableIntStateOf(0) }
|
||||
var talkChatsRequest by remember { mutableIntStateOf(0) }
|
||||
var mailPushRequest by remember { mutableIntStateOf(0) }
|
||||
var talkPushRequest by remember { mutableIntStateOf(0) }
|
||||
var filesPushRequest by remember { mutableIntStateOf(0) }
|
||||
var notificationsPushRequest by remember { mutableIntStateOf(0) }
|
||||
var pushTalkRoomToken by remember { mutableStateOf<String?>(null) }
|
||||
var hasNotificationBadge by rememberSaveable { mutableStateOf(false) }
|
||||
val permissionPrefs = remember {
|
||||
context.applicationContext.getSharedPreferences("f7_permissions", android.content.Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
val forceLogout = {
|
||||
OfficeWarmup.clear()
|
||||
OfficeWebViewPool.dispose()
|
||||
authStore.clear()
|
||||
session = null
|
||||
}
|
||||
LaunchedEffect(session?.serverUrl, session?.username) {
|
||||
session?.let { OfficeWarmup.warm(it) }
|
||||
}
|
||||
LaunchedEffect(openUrl) {
|
||||
if (!openUrl.isNullOrBlank()) {
|
||||
pendingOpenUrl = openUrl
|
||||
onOpenUrlConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
F7Theme {
|
||||
if (session == null) {
|
||||
LoginScreen(onLogin = {
|
||||
authStore.save(it)
|
||||
session = it
|
||||
})
|
||||
return@F7Theme
|
||||
}
|
||||
|
||||
val currentSession = session!!
|
||||
var externalMenuSites by remember { mutableStateOf<List<AppMenuExternalSite>>(emptyList()) }
|
||||
val appMenuItemsList = remember(currentSession.serverUrl, activeTab, externalMenuSites) {
|
||||
appMenuItems(currentSession.serverUrl, activeTab, externalMenuSites)
|
||||
}
|
||||
LaunchedEffect(menuOpen, currentSession.serverUrl, currentSession.username) {
|
||||
if (!menuOpen) return@LaunchedEffect
|
||||
externalMenuSites = withContext(Dispatchers.IO) {
|
||||
AppMenuRepository.fetchExternalSites(currentSession)
|
||||
}
|
||||
}
|
||||
val lockStore = remember { AppLockStore(context) }
|
||||
var showAppLockSetup by rememberSaveable { mutableStateOf(false) }
|
||||
var lockUnlockNonce by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(currentSession.serverUrl, currentSession.username) {
|
||||
if (lockStore.shouldOfferSetup()) {
|
||||
showAppLockSetup = true
|
||||
}
|
||||
}
|
||||
AppLockSetupDialog(
|
||||
visible = showAppLockSetup,
|
||||
lockStore = lockStore,
|
||||
onDismiss = { showAppLockSetup = false },
|
||||
onLockConfigured = { lockUnlockNonce++ },
|
||||
)
|
||||
AppLockGate(lockStore = lockStore, unlockNonce = lockUnlockNonce) {
|
||||
fun applyAppLink(target: AppLinkTarget) {
|
||||
activeTab = target.tab
|
||||
pendingTalkRoomToken = target.talkRoomToken
|
||||
pendingTalkMessageId = target.talkMessageId
|
||||
pendingSupportTicket = target.supportTicket
|
||||
pendingMailMessageId = target.mailMessageId ?: -1
|
||||
pendingMailMailboxId = target.mailMailboxId ?: -1
|
||||
pendingCalendarEventUid = target.calendarEventUid
|
||||
pendingDeckCardId = target.deckCardId ?: -1
|
||||
pendingFileId = target.fileId
|
||||
pendingTasksListSlug = target.tasksListSlug
|
||||
}
|
||||
fun openAppLink(url: String) {
|
||||
AppLinkResolver.resolve(url, currentSession.serverUrl)?.let { applyAppLink(it) }
|
||||
}
|
||||
LaunchedEffect(pendingOpenUrl) {
|
||||
val url = pendingOpenUrl ?: return@LaunchedEffect
|
||||
openAppLink(url)
|
||||
pendingOpenUrl = null
|
||||
}
|
||||
val userId = currentSession.davUserId ?: currentSession.username
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var browserQrBusy by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val browserQrLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
|
||||
?: return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
browserQrBusy = true
|
||||
val approved = LoginFlowClient.approveBrowserLoginFromQr(
|
||||
qrData = qrData,
|
||||
username = currentSession.username,
|
||||
appPassword = currentSession.appPassword,
|
||||
trustAllCerts = currentSession.trustAllCerts,
|
||||
)
|
||||
browserQrBusy = false
|
||||
val message = if (approved) {
|
||||
"Браузер авторизован"
|
||||
} else {
|
||||
"Не удалось подтвердить вход в браузере"
|
||||
}
|
||||
Toast.makeText(context.applicationContext, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
val browserQrCameraLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
browserQrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun launchBrowserQrScan() {
|
||||
if (browserQrBusy) return
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
browserQrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||
} else {
|
||||
browserQrCameraLauncher.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
var showPermissionRationale by rememberSaveable { mutableStateOf(false) }
|
||||
var permissionFlowStarted by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
fun registerFcmPush() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val token = FirebaseMessaging.getInstance().token.await()
|
||||
val code = F7PushRegistrar.registerBlocking(context, currentSession, token)
|
||||
android.util.Log.i("F7Push", "AppScaffold register result: $code")
|
||||
}.onFailure {
|
||||
android.util.Log.e("F7Push", "AppScaffold register failed", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startPermissionRequest() {
|
||||
permissionFlowStarted = true
|
||||
onRequestRuntimePermissions { registerFcmPush() }
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, currentSession.serverUrl, currentSession.username) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event != Lifecycle.Event.ON_RESUME) return@LifecycleEventObserver
|
||||
if (permissionFlowStarted) return@LifecycleEventObserver
|
||||
if (F7AppPermissions.hasAll(context)) {
|
||||
permissionFlowStarted = true
|
||||
registerFcmPush()
|
||||
return@LifecycleEventObserver
|
||||
}
|
||||
val alreadyPrompted = permissionPrefs.getBoolean(PERMISSIONS_PROMPTED_KEY, false)
|
||||
if (!alreadyPrompted) {
|
||||
permissionFlowStarted = true
|
||||
showPermissionRationale = true
|
||||
} else {
|
||||
startPermissionRequest()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
F7PermissionRationaleDialog(
|
||||
visible = showPermissionRationale,
|
||||
onConfirm = {
|
||||
showPermissionRationale = false
|
||||
permissionPrefs.edit().putBoolean(PERMISSIONS_PROMPTED_KEY, true).apply()
|
||||
startPermissionRequest()
|
||||
},
|
||||
onDismiss = {
|
||||
showPermissionRationale = false
|
||||
permissionPrefs.edit().putBoolean(PERMISSIONS_PROMPTED_KEY, true).apply()
|
||||
registerFcmPush()
|
||||
},
|
||||
)
|
||||
|
||||
LaunchedEffect(activeTab) {
|
||||
if (activeTab == AppTab.Talk && !F7AppPermissions.hasAll(context)) {
|
||||
delay(400)
|
||||
onRequestRuntimePermissions(null)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
F7PushEventHub.events.collect { event ->
|
||||
when (event) {
|
||||
is F7PushEvent.Mail -> {
|
||||
mailPushRequest++
|
||||
event.messageId?.let { pendingMailMessageId = it }
|
||||
event.mailboxId?.let { pendingMailMailboxId = it }
|
||||
}
|
||||
is F7PushEvent.Talk -> {
|
||||
pushTalkRoomToken = event.roomToken
|
||||
talkPushRequest++
|
||||
event.messageId?.let { pendingTalkMessageId = it }
|
||||
event.roomToken?.let { pendingTalkRoomToken = it }
|
||||
}
|
||||
is F7PushEvent.Files -> {
|
||||
filesPushRequest++
|
||||
event.fileId?.let { pendingFileId = it }
|
||||
}
|
||||
is F7PushEvent.Notification -> {
|
||||
notificationsPushRequest++
|
||||
hasNotificationBadge = true
|
||||
}
|
||||
is F7PushEvent.Call -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(activeTab) {
|
||||
if (activeTab != AppTab.Mail) {
|
||||
mailInMessage = false
|
||||
}
|
||||
if (activeTab != AppTab.Calendar) {
|
||||
calendarSidebarOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(mailInMessage) {
|
||||
if (mailInMessage) {
|
||||
bottomBarActivity++
|
||||
}
|
||||
}
|
||||
|
||||
val bottomBarConfig = remember(activeTab, talkInRoom) {
|
||||
F7BottomBarConfig.forContext(activeTab.name, talkInRoom)
|
||||
}
|
||||
|
||||
fun pushTabHistory(from: AppTab) {
|
||||
tabHistory = (tabHistory + from.name).takeLast(20)
|
||||
}
|
||||
fun popTabHistory(): AppTab? {
|
||||
if (tabHistory.isEmpty()) return null
|
||||
val name = tabHistory.last()
|
||||
tabHistory = tabHistory.dropLast(1)
|
||||
return runCatching { AppTab.valueOf(name) }.getOrNull()
|
||||
}
|
||||
fun dismissSwipeOverlay(): Boolean {
|
||||
when {
|
||||
menuOpen -> menuOpen = false
|
||||
profileOpen -> profileOpen = false
|
||||
notificationsOpen -> notificationsOpen = false
|
||||
mailSettingsOpen -> mailSettingsOpen = false
|
||||
filesSettingsOpen -> filesSettingsOpen = false
|
||||
mailSidebarOpen -> mailSidebarOpen = false
|
||||
calendarSidebarOpen -> calendarSidebarOpen = false
|
||||
filesSidebarOpen -> filesSidebarOpen = false
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
menuOpen -> menuOpen = false
|
||||
profileOpen -> profileOpen = false
|
||||
notificationsOpen -> notificationsOpen = false
|
||||
mailSidebarOpen -> mailSidebarOpen = false
|
||||
calendarSidebarOpen -> calendarSidebarOpen = false
|
||||
filesSidebarOpen -> filesSidebarOpen = false
|
||||
mailSettingsOpen -> mailSettingsOpen = false
|
||||
filesSettingsOpen -> filesSettingsOpen = false
|
||||
else -> {
|
||||
val previous = popTabHistory()
|
||||
if (previous != null) {
|
||||
activeTab = previous
|
||||
} else {
|
||||
(context as? Activity)?.moveTaskToBack(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
F7OverlayNavigationProvider(
|
||||
onSwipeDismiss = ::dismissSwipeOverlay,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(pass = PointerEventPass.Initial)
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastBottomBarPulse >= 800L) {
|
||||
lastBottomBarPulse = now
|
||||
bottomBarActivity++
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
F7AppScaffold(
|
||||
bottomBar = {
|
||||
if ((activeTab != AppTab.Talk || !talkInRoom)) {
|
||||
F7AutoHideBottomBar(
|
||||
enabled = true,
|
||||
pinned = bottomBarPinned,
|
||||
activityNonce = bottomBarActivity,
|
||||
hideDelayMs = 4000L,
|
||||
) {
|
||||
F7MobileBottomBar(
|
||||
serverUrl = currentSession.serverUrl,
|
||||
userId = userId,
|
||||
config = bottomBarConfig,
|
||||
menuOpen = menuOpen,
|
||||
chatsHighlighted = activeTab == AppTab.Talk && !talkInRoom,
|
||||
navBackHighlighted = (activeTab == AppTab.Mail && mailSidebarOpen) ||
|
||||
(activeTab == AppTab.Calendar && calendarSidebarOpen) ||
|
||||
(activeTab == AppTab.Files && filesSidebarOpen),
|
||||
showNotificationBadge = hasNotificationBadge,
|
||||
actions = F7BottomBarActions(
|
||||
onChatsClick = { talkChatsRequest++ },
|
||||
onNavBackClick = {
|
||||
when (activeTab) {
|
||||
AppTab.Mail -> mailSidebarOpen = !mailSidebarOpen
|
||||
AppTab.Calendar -> calendarSidebarOpen = !calendarSidebarOpen
|
||||
AppTab.Files -> filesSidebarOpen = !filesSidebarOpen
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
onCreateClick = {
|
||||
when (activeTab) {
|
||||
AppTab.Files -> {
|
||||
if (F7AppPermissions.missing(context).isNotEmpty()) {
|
||||
onRequestRuntimePermissions { filesUploadRequest++ }
|
||||
} else {
|
||||
filesUploadRequest++
|
||||
}
|
||||
}
|
||||
AppTab.Contacts -> contactsCreateRequest++
|
||||
AppTab.Tasks -> tasksCreateRequest++
|
||||
AppTab.Support -> supportCreateRequest++
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
onProfileClick = {
|
||||
menuOpen = false
|
||||
profileOpen = true
|
||||
},
|
||||
onNotificationsClick = {
|
||||
menuOpen = false
|
||||
hasNotificationBadge = false
|
||||
notificationsOpen = true
|
||||
},
|
||||
onSettingsClick = {
|
||||
when (activeTab) {
|
||||
AppTab.Mail -> mailSettingsOpen = true
|
||||
AppTab.Calendar -> calendarSettingsRequest++
|
||||
AppTab.Files -> filesSettingsOpen = true
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
onMenuClick = { menuOpen = !menuOpen },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { contentModifier ->
|
||||
when (activeTab) {
|
||||
AppTab.Mail -> MailScreen(
|
||||
session = currentSession,
|
||||
modifier = contentModifier,
|
||||
openMessageId = pendingMailMessageId.takeIf { it > 0 },
|
||||
openMailboxId = pendingMailMailboxId.takeIf { it > 0 },
|
||||
sidebarOpen = mailSidebarOpen,
|
||||
onSidebarOpenChange = { mailSidebarOpen = it },
|
||||
settingsOpen = mailSettingsOpen,
|
||||
onSettingsOpenChange = { mailSettingsOpen = it },
|
||||
pushRefreshRequest = mailPushRequest,
|
||||
onOpenMessageConsumed = { pendingMailMessageId = -1; pendingMailMailboxId = -1 },
|
||||
onMessageOpenStateChange = { mailInMessage = it },
|
||||
onUnauthorized = forceLogout,
|
||||
)
|
||||
AppTab.Files -> FilesScreen(
|
||||
session = currentSession,
|
||||
modifier = contentModifier,
|
||||
uploadRequest = filesUploadRequest,
|
||||
pushRefreshRequest = filesPushRequest,
|
||||
openFileId = pendingFileId,
|
||||
sidebarOpen = filesSidebarOpen,
|
||||
onSidebarOpenChange = { filesSidebarOpen = it },
|
||||
settingsOpen = filesSettingsOpen,
|
||||
onSettingsOpenChange = { filesSettingsOpen = it },
|
||||
onOpenFileConsumed = { pendingFileId = null },
|
||||
onUnauthorized = forceLogout,
|
||||
onOpenOfficeEditor = { launch ->
|
||||
context.startActivity(OfficeEditorActivity.intent(context, launch))
|
||||
},
|
||||
)
|
||||
AppTab.Calendar -> CalendarScreen(
|
||||
session = currentSession,
|
||||
modifier = contentModifier,
|
||||
focusEventUid = pendingCalendarEventUid,
|
||||
settingsRequest = calendarSettingsRequest,
|
||||
sidebarOpen = calendarSidebarOpen,
|
||||
onSidebarOpenChange = { calendarSidebarOpen = it },
|
||||
onFocusEventConsumed = { pendingCalendarEventUid = null },
|
||||
onUnauthorized = forceLogout,
|
||||
)
|
||||
AppTab.Contacts -> ContactsScreen(
|
||||
session = currentSession,
|
||||
modifier = contentModifier,
|
||||
createRequest = contactsCreateRequest,
|
||||
onUnauthorized = forceLogout,
|
||||
)
|
||||
AppTab.Talk -> {
|
||||
val onTalkConsumed = {
|
||||
pendingTalkRoomToken = null
|
||||
pendingTalkMessageId = null
|
||||
}
|
||||
TalkScreen(
|
||||
session = currentSession,
|
||||
modifier = contentModifier,
|
||||
openRoomToken = pendingTalkRoomToken,
|
||||
scrollToMessageId = pendingTalkMessageId,
|
||||
chatsListRequest = talkChatsRequest,
|
||||
pushSyncRequest = talkPushRequest,
|
||||
pushRoomToken = pushTalkRoomToken,
|
||||
onOpenRoomConsumed = onTalkConsumed,
|
||||
onRoomOpenStateChange = { talkInRoom = it },
|
||||
onOpenCalendar = { activeTab = AppTab.Calendar },
|
||||
onUnauthorized = forceLogout,
|
||||
)
|
||||
}
|
||||
AppTab.Deck -> DeckScreen(
|
||||
session = currentSession,
|
||||
modifier = contentModifier,
|
||||
openCardId = pendingDeckCardId.takeIf { it > 0 },
|
||||
onOpenCardConsumed = { pendingDeckCardId = -1 },
|
||||
onUnauthorized = forceLogout,
|
||||
)
|
||||
AppTab.Tasks -> TasksScreen(
|
||||
session = currentSession,
|
||||
modifier = contentModifier,
|
||||
createRequest = tasksCreateRequest,
|
||||
openListSlug = pendingTasksListSlug,
|
||||
onOpenListConsumed = { pendingTasksListSlug = null },
|
||||
onUnauthorized = forceLogout,
|
||||
)
|
||||
AppTab.Support -> SupportScreen(
|
||||
session = currentSession,
|
||||
modifier = contentModifier,
|
||||
createRequest = supportCreateRequest,
|
||||
openTicketNumber = pendingSupportTicket,
|
||||
onOpenTicketConsumed = { pendingSupportTicket = null },
|
||||
onUnauthorized = forceLogout,
|
||||
)
|
||||
}
|
||||
}
|
||||
F7AppMenuSheet(
|
||||
visible = menuOpen,
|
||||
serverUrl = currentSession.serverUrl,
|
||||
items = appMenuItemsList,
|
||||
onDismiss = { menuOpen = false },
|
||||
onItemClick = { index ->
|
||||
val item = appMenuItemsList.getOrNull(index) ?: return@F7AppMenuSheet
|
||||
val external = item.externalUrl
|
||||
if (!external.isNullOrBlank()) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
|
||||
}
|
||||
menuOpen = false
|
||||
} else {
|
||||
appTabFromMenuIndex(index)?.let { tab ->
|
||||
if (tab != activeTab) {
|
||||
pushTabHistory(activeTab)
|
||||
activeTab = tab
|
||||
}
|
||||
}
|
||||
menuOpen = false
|
||||
}
|
||||
},
|
||||
)
|
||||
ProfileSheet(
|
||||
visible = profileOpen,
|
||||
session = currentSession,
|
||||
onDismiss = { profileOpen = false },
|
||||
onLogout = forceLogout,
|
||||
onScanBrowserQr = ::launchBrowserQrScan,
|
||||
)
|
||||
NotificationsSheet(
|
||||
visible = notificationsOpen,
|
||||
session = currentSession,
|
||||
refreshRequest = notificationsPushRequest,
|
||||
onDismiss = { notificationsOpen = false },
|
||||
onUnauthorized = forceLogout,
|
||||
onNotificationClick = { notification ->
|
||||
openAppLink(notification.link)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2"
|
||||
@Composable
|
||||
private fun LoginScreen(onLogin: (AuthSession) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val serverFocus = remember { FocusRequester() }
|
||||
val usernameFocus = remember { FocusRequester() }
|
||||
val passwordFocus = remember { FocusRequester() }
|
||||
var serverUrl by rememberSaveable { mutableStateOf("https://forbion.f7cloud.ru") }
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
|
||||
var loading by rememberSaveable { mutableStateOf(false) }
|
||||
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val qrLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
|
||||
?: return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
|
||||
if (loginResult == null) {
|
||||
error = if (qrData.contains("/login/v2/flow/")) {
|
||||
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
|
||||
} else {
|
||||
"Не удалось распознать QR-код"
|
||||
}
|
||||
loading = false
|
||||
return@launch
|
||||
}
|
||||
val newSession = AuthSession(
|
||||
serverUrl = normalizeServerUrl(loginResult.serverUrl),
|
||||
username = loginResult.username,
|
||||
appPassword = loginResult.appPassword,
|
||||
trustAllCerts = trustAllCerts,
|
||||
)
|
||||
AuthVerifier.verify(newSession)
|
||||
.onSuccess { verified -> onLogin(verified) }
|
||||
.onFailure { error = it.message ?: "Ошибка входа по QR" }
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun launchQrScan() {
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||
} else {
|
||||
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
fun submitLogin() {
|
||||
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||
return
|
||||
}
|
||||
val newSession = AuthSession(
|
||||
serverUrl = normalizeServerUrl(serverUrl),
|
||||
username = username.trim(),
|
||||
appPassword = password,
|
||||
trustAllCerts = trustAllCerts,
|
||||
)
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
AuthVerifier.verify(newSession)
|
||||
.onSuccess { verified -> onLogin(verified) }
|
||||
.onFailure { error = it.message ?: "Ошибка входа" }
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
val logoUrl = remember(serverUrl) {
|
||||
"${normalizeServerUrl(serverUrl).trimEnd('/')}/themes/forbion/images/login/big-forbion.svg"
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.f7SafeTopInsets()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 440.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
contentDescription = "Forbion",
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(70.dp)
|
||||
.padding(bottom = 48.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = F7Colors.Background,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Вход",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
lineHeight = 20.sp,
|
||||
color = F7Colors.TextPrimary,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
Text(
|
||||
"Используйте тот же пароль, что и для входа в веб-интерфейс. " +
|
||||
"Если включена двухфакторная аутентификация — нужен пароль приложения.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = "Адрес сервера",
|
||||
modifier = Modifier
|
||||
.focusRequester(serverFocus)
|
||||
.focusProperties { next = usernameFocus },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { usernameFocus.requestFocus() },
|
||||
),
|
||||
onEnter = { usernameFocus.requestFocus() },
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = "Имя пользователя",
|
||||
modifier = Modifier
|
||||
.focusRequester(usernameFocus)
|
||||
.focusProperties { next = passwordFocus },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { passwordFocus.requestFocus() },
|
||||
),
|
||||
onEnter = { passwordFocus.requestFocus() },
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = "Пароль",
|
||||
modifier = Modifier.focusRequester(passwordFocus),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
onGo = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
),
|
||||
onEnter = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = trustAllCerts,
|
||||
onCheckedChange = { trustAllCerts = it },
|
||||
)
|
||||
Text(
|
||||
text = "Доверять сертификату (dev)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
F7PrimaryButton(
|
||||
text = if (loading) "…" else "Войти",
|
||||
onClick = { submitLogin() },
|
||||
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
F7TextButton(
|
||||
text = "Сканировать QR для входа",
|
||||
onClick = { launchQrScan() },
|
||||
enabled = !loading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (loading) {
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
}
|
||||
if (error != null) {
|
||||
Text(text = error ?: "", color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||
F7TextButton(
|
||||
text = "Очистить",
|
||||
onClick = {
|
||||
error = null
|
||||
password = ""
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Credentials
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7FloatingPanel
|
||||
import ru.forbion.f7cloud.core.designsystem.F7NotificationRow
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||
import ru.forbion.f7cloud.core.designsystem.formatNotificationRelativeTime
|
||||
import ru.forbion.f7cloud.core.network.F7Notification
|
||||
import ru.forbion.f7cloud.core.network.NotificationsRepository
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
|
||||
private fun themeHeaderAsset(serverUrl: String, fileName: String): String =
|
||||
"${serverUrl.trimEnd('/')}/themes/forbion/images/header/$fileName"
|
||||
|
||||
private fun resolveNotificationIcon(serverUrl: String, icon: String): String? {
|
||||
if (icon.isBlank()) return null
|
||||
return if (icon.startsWith("http")) icon else "${serverUrl.trimEnd('/')}$icon"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileSheet(
|
||||
visible: Boolean,
|
||||
session: AuthSession,
|
||||
onDismiss: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onScanBrowserQr: () -> Unit = {},
|
||||
) {
|
||||
F7FloatingPanel(
|
||||
visible = visible,
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
Text("Профиль", style = MaterialTheme.typography.titleMedium, color = F7Colors.TextPrimary)
|
||||
Text(
|
||||
text = session.username,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
Text(
|
||||
text = session.serverUrl,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
session.davUserId?.let { davId ->
|
||||
Text(
|
||||
text = "ID: $davId",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
F7PrimaryButton(
|
||||
text = "Сканировать QR браузера",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onScanBrowserQr()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
F7PrimaryButton(
|
||||
text = "Выйти",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onLogout()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
F7TextButton(text = "Закрыть", onClick = onDismiss, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationsSheet(
|
||||
visible: Boolean,
|
||||
session: AuthSession,
|
||||
refreshRequest: Int = 0,
|
||||
onDismiss: () -> Unit,
|
||||
onNotificationClick: (F7Notification) -> Unit,
|
||||
onUnauthorized: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val repository = remember { NotificationsRepository() }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var items by remember { mutableStateOf<List<F7Notification>>(emptyList()) }
|
||||
val authHeader = remember(session.username, session.appPassword) {
|
||||
Credentials.basic(session.username, session.appPassword)
|
||||
}
|
||||
val closeIconUrl = remember(session.serverUrl) {
|
||||
themeHeaderAsset(session.serverUrl, "close-modal.svg")
|
||||
}
|
||||
|
||||
fun loadNotifications() {
|
||||
loading = true
|
||||
error = null
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
repository.load(
|
||||
serverUrl = session.serverUrl,
|
||||
username = session.username,
|
||||
appPassword = session.appPassword,
|
||||
trustAllCerts = session.trustAllCerts,
|
||||
)
|
||||
}
|
||||
}
|
||||
.onSuccess { list ->
|
||||
items = list
|
||||
loading = false
|
||||
}
|
||||
.onFailure { t ->
|
||||
loading = false
|
||||
if (t is UnauthorizedException) {
|
||||
onDismiss()
|
||||
onUnauthorized()
|
||||
} else {
|
||||
error = t.message ?: "Не удалось загрузить уведомления"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(visible, refreshRequest, session.serverUrl, session.username) {
|
||||
if (!visible && refreshRequest == 0) return@LaunchedEffect
|
||||
loadNotifications()
|
||||
}
|
||||
|
||||
fun dismissNotification(notification: F7Notification) {
|
||||
items = items.filter { it.id != notification.id }
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
repository.dismiss(
|
||||
serverUrl = session.serverUrl,
|
||||
username = session.username,
|
||||
appPassword = session.appPassword,
|
||||
notificationId = notification.id,
|
||||
trustAllCerts = session.trustAllCerts,
|
||||
)
|
||||
}
|
||||
}.onFailure { t ->
|
||||
if (t is UnauthorizedException) {
|
||||
onDismiss()
|
||||
onUnauthorized()
|
||||
} else {
|
||||
loadNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
F7FloatingPanel(
|
||||
visible = visible,
|
||||
onDismiss = onDismiss,
|
||||
fullHeight = true,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
when {
|
||||
loading -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
}
|
||||
}
|
||||
!error.isNullOrBlank() -> {
|
||||
Text(
|
||||
error ?: "",
|
||||
color = F7Colors.Error,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = MaterialTheme.typography.bodyLarge.fontSize * 2,
|
||||
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 2,
|
||||
),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
items.isEmpty() -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = themeHeaderAsset(session.serverUrl, "nof-not-icon.svg"),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(120.dp),
|
||||
)
|
||||
Text(
|
||||
"Нет уведомлений",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize * 2,
|
||||
lineHeight = MaterialTheme.typography.titleMedium.lineHeight * 2,
|
||||
),
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
items.forEachIndexed { index, item ->
|
||||
F7NotificationRow(
|
||||
subject = item.subject,
|
||||
message = item.message,
|
||||
relativeTime = formatNotificationRelativeTime(item.datetime),
|
||||
iconUrl = resolveNotificationIcon(session.serverUrl, item.icon),
|
||||
closeIconUrl = closeIconUrl,
|
||||
authHeader = authHeader,
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onNotificationClick(item)
|
||||
},
|
||||
onDismiss = { dismissNotification(item) },
|
||||
showDivider = index < items.lastIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.55 -0.45,1 -1,1H4.01c-0.55,0 -1,0.45 -1,1v4c0,0.55 0.45,1 1,1h4c0.55,0 1,-0.45 1,-1v-3.1c1.45,0.47 3,0.72 4.6,0.72s3.15,-0.25 4.6,-0.72v3.1c0,0.55 0.45,1 1,1h4c0.55,0 1,-0.45 1,-1v-4c0,-0.55 -0.45,-1 -1,-1h-2.39c-0.55,0 -1,-0.45 -1,-1v-3.1C15.15,9.25 13.6,9 12,9z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 776 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 483 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">F7cloud Mobile</string>
|
||||
<string name="shortcut_talk_short">Конференции</string>
|
||||
<string name="shortcut_talk_long">Открыть F7cloud Talk</string>
|
||||
|
||||
<string name="qr_scan_title">Сканирование QR-кода</string>
|
||||
<string name="qr_scan_close">Закрыть</string>
|
||||
<string name="qr_scan_hint_idle">Наведите камеру на QR-код</string>
|
||||
<string name="qr_scan_hint_align">Держите QR-код целиком в рамке</string>
|
||||
<string name="qr_scan_hint_progress">Распознавание… (%1$d из %2$d)</string>
|
||||
<string name="qr_scan_hint_done">Готово</string>
|
||||
<string name="qr_scan_torch_on">Вспышка</string>
|
||||
<string name="qr_scan_torch_off">Выкл.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<shortcut
|
||||
android:shortcutId="talk"
|
||||
android:enabled="true"
|
||||
android:icon="@android:drawable/stat_notify_chat"
|
||||
android:shortcutShortLabel="@string/shortcut_talk_short"
|
||||
android:shortcutLongLabel="@string/shortcut_talk_long">
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetPackage="ru.forbion.f7cloud.mobile"
|
||||
android:targetClass="ru.forbion.f7cloud.mobile.MainActivity"
|
||||
android:data="f7cloud://talk" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
||||