mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement deep links
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
Generated
+3
-1
@@ -1,6 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="shared">
|
||||
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
|
||||
<root id="archive" name="shared.jar" />
|
||||
<root id="archive" name="shared.jar">
|
||||
<element id="module-output" name="FromChat.utils.shared.androidMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
+1
@@ -8,5 +8,6 @@
|
||||
</component>
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/.cursor/skills/material-3-skill" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -26,6 +26,15 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="fromchat"
|
||||
android:host="u"
|
||||
android:pathPrefix="/" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".fcm.FromChatFirebaseMessagingService"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.fromchat
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
@@ -12,6 +13,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import io.ktor.client.call.body
|
||||
@@ -20,12 +22,15 @@ import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.MessagesResponse
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.core.Logger
|
||||
import ru.fromchat.ui.App
|
||||
import ru.fromchat.ui.isPublicChatVisible
|
||||
|
||||
@@ -35,30 +40,150 @@ private const val EXTRA_MARK_MESSAGE_READ = "mark_message_read"
|
||||
private const val EXTRA_MESSAGE_ID = "scroll_to_message_id"
|
||||
private const val CHAT_TYPE_PUBLIC = "public"
|
||||
private const val CHAT_TYPE_DM = "dm"
|
||||
private const val INVALID_PROFILE_DEEP_LINK_MESSAGE = "Could not open this profile link. Use fromchat://u/<idOrUsername>."
|
||||
private const val PROFILE_NOT_FOUND_MESSAGE = "This profile could not be found"
|
||||
private const val PROFILE_OPEN_FAILED_MESSAGE = "Could not open this profile. Please try again."
|
||||
|
||||
private data class ProfileDeepLinkResolution(
|
||||
val scrollToMessageId: Int? = null,
|
||||
val startAtPublicChat: Boolean = false,
|
||||
val startAtDmConversationUserId: Int? = null,
|
||||
val startAtProfileUserId: Int? = null,
|
||||
val startAtProfileUsername: String? = null,
|
||||
val profileLookupErrorMessage: String? = null
|
||||
)
|
||||
|
||||
private fun getProfileLookupFailureMessage(error: Throwable): String =
|
||||
if (error is ClientRequestException && error.response.status.value == 404) {
|
||||
PROFILE_NOT_FOUND_MESSAGE
|
||||
} else {
|
||||
PROFILE_OPEN_FAILED_MESSAGE
|
||||
}
|
||||
|
||||
private data class ProfileDeepLinkTarget(
|
||||
val userId: Int? = null,
|
||||
val username: String? = null,
|
||||
val parseError: String? = null
|
||||
)
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private var scrollToMessageId by mutableStateOf<Int?>(null)
|
||||
private var startAtPublicChat by mutableStateOf(false)
|
||||
private var startAtDmConversationUserId by mutableStateOf<Int?>(null)
|
||||
private var startAtProfileUserId by mutableStateOf<Int?>(null)
|
||||
private var startAtProfileUsername by mutableStateOf<String?>(null)
|
||||
private var profileLookupErrorMessage by mutableStateOf<String?>(null)
|
||||
private var prevIsPublicChatVisible: Boolean? = null
|
||||
private val requestNotificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) {}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
private suspend fun buildLaunchState(intent: Intent?): ProfileDeepLinkResolution {
|
||||
Logger.d(
|
||||
"ProfileDeepLink",
|
||||
"handleIntent: action=${intent?.action}, data=${intent?.dataString}, messageId=${intent?.getIntExtra(EXTRA_MESSAGE_ID, -1)}, " +
|
||||
"chatType=${intent?.getStringExtra(EXTRA_NOTIFICATION_CHAT_TYPE)}, openDmUserId=${intent?.getIntExtra(EXTRA_OPEN_DM_USER_ID, -1)}"
|
||||
)
|
||||
val messageId = intent?.getIntExtra(EXTRA_MESSAGE_ID, -1) ?: -1
|
||||
val chatType = intent?.getStringExtra(EXTRA_NOTIFICATION_CHAT_TYPE) ?: CHAT_TYPE_PUBLIC
|
||||
val dmConversationUserId = intent?.getIntExtra(EXTRA_OPEN_DM_USER_ID, -1) ?: -1
|
||||
val profileTarget = parseProfileDeepLink(intent)
|
||||
Logger.d(
|
||||
"ProfileDeepLink",
|
||||
"handleIntent parsedProfileTarget: userId=${profileTarget?.userId}, username=${profileTarget?.username}, parseError=${profileTarget?.parseError}"
|
||||
)
|
||||
|
||||
scrollToMessageId = if (messageId != -1) messageId else null
|
||||
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM
|
||||
val baseState = ProfileDeepLinkResolution(
|
||||
scrollToMessageId = if (messageId != -1) messageId else null,
|
||||
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM,
|
||||
startAtDmConversationUserId = if (chatType == CHAT_TYPE_DM && dmConversationUserId > 0) {
|
||||
dmConversationUserId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
|
||||
if (profileTarget?.parseError != null) {
|
||||
return baseState.copy(profileLookupErrorMessage = profileTarget.parseError)
|
||||
}
|
||||
|
||||
if (profileTarget == null) {
|
||||
return baseState
|
||||
}
|
||||
|
||||
var lookupFailureMessage: String? = null
|
||||
val resolvedProfileId = if (profileTarget.userId != null) {
|
||||
runCatching {
|
||||
ApiClient.getProfileById(profileTarget.userId).id
|
||||
}.onFailure { err ->
|
||||
Logger.d("ProfileDeepLink", "profile deep link lookup failed by id: ${err.message}")
|
||||
lookupFailureMessage = getProfileLookupFailureMessage(err)
|
||||
}.getOrNull()
|
||||
} else if (!profileTarget.username.isNullOrBlank()) {
|
||||
runCatching {
|
||||
ApiClient.getProfileByUsername(profileTarget.username).id
|
||||
}.onFailure { err ->
|
||||
Logger.d("ProfileDeepLink", "profile deep link lookup failed by username: ${err.message}")
|
||||
lookupFailureMessage = getProfileLookupFailureMessage(err)
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (resolvedProfileId == null) {
|
||||
return baseState.copy(profileLookupErrorMessage = lookupFailureMessage ?: PROFILE_OPEN_FAILED_MESSAGE)
|
||||
}
|
||||
|
||||
// Mark messages as read if clicked from notification
|
||||
if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) {
|
||||
markMessagesAsRead()
|
||||
}
|
||||
|
||||
return baseState.copy(
|
||||
startAtProfileUserId = resolvedProfileId,
|
||||
startAtProfileUsername = null,
|
||||
startAtDmConversationUserId = null,
|
||||
startAtPublicChat = false
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyLaunchState(launchState: ProfileDeepLinkResolution) {
|
||||
scrollToMessageId = launchState.scrollToMessageId
|
||||
startAtPublicChat = launchState.startAtPublicChat
|
||||
startAtDmConversationUserId = launchState.startAtDmConversationUserId
|
||||
startAtProfileUserId = launchState.startAtProfileUserId
|
||||
startAtProfileUsername = launchState.startAtProfileUsername
|
||||
profileLookupErrorMessage = launchState.profileLookupErrorMessage
|
||||
}
|
||||
|
||||
private fun parseProfileDeepLink(intent: Intent?): ProfileDeepLinkTarget? {
|
||||
val data: Uri = intent?.data ?: return null
|
||||
Logger.d("ProfileDeepLink", "parseProfileDeepLink intentData=${data.toString()}")
|
||||
if (!data.scheme.equals("fromchat", ignoreCase = true)) return null
|
||||
if (data.host != "u") return null
|
||||
|
||||
val segments = data.pathSegments.filter { it.isNotBlank() }
|
||||
Logger.d("ProfileDeepLink", "parseProfileDeepLink segments=${segments.joinToString(",")}")
|
||||
if (segments.size != 1) {
|
||||
return ProfileDeepLinkTarget(parseError = INVALID_PROFILE_DEEP_LINK_MESSAGE)
|
||||
}
|
||||
val segment = segments[0].let { Uri.decode(it) }
|
||||
val trimmed = segment.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
return ProfileDeepLinkTarget(parseError = INVALID_PROFILE_DEEP_LINK_MESSAGE)
|
||||
}
|
||||
|
||||
return trimmed.toLongOrNull()?.let { idLong ->
|
||||
if (idLong in 1L..Int.MAX_VALUE.toLong()) {
|
||||
Logger.d("ProfileDeepLink", "parseProfileDeepLink resolved as userId=$idLong")
|
||||
ProfileDeepLinkTarget(userId = idLong.toInt())
|
||||
} else {
|
||||
Logger.d("ProfileDeepLink", "parseProfileDeepLink large numeric treated as username=$trimmed")
|
||||
ProfileDeepLinkTarget(username = trimmed)
|
||||
}
|
||||
} ?: run {
|
||||
Logger.d("ProfileDeepLink", "parseProfileDeepLink resolved as username=$trimmed")
|
||||
ProfileDeepLinkTarget(username = trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@@ -110,30 +235,38 @@ class MainActivity : ComponentActivity() {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Handle initial intent
|
||||
handleIntent(intent)
|
||||
|
||||
lifecycleScope.launch {
|
||||
val launchState = buildLaunchState(intent)
|
||||
applyLaunchState(launchState)
|
||||
setContent {
|
||||
App(
|
||||
scrollToMessageId = scrollToMessageId,
|
||||
startAtPublicChat = startAtPublicChat,
|
||||
startAtDmConversationUserId = startAtDmConversationUserId
|
||||
)
|
||||
startAtDmConversationUserId = startAtDmConversationUserId,
|
||||
startAtProfileUserId = startAtProfileUserId,
|
||||
startAtProfileUsername = startAtProfileUsername,
|
||||
profileLookupErrorMessage = profileLookupErrorMessage,
|
||||
onProfileLookupErrorMessageConsumed = {
|
||||
profileLookupErrorMessage = null
|
||||
}
|
||||
|
||||
// Request POST_NOTIFICATIONS permission on Android 13+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) {}.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
)
|
||||
}
|
||||
|
||||
checkGooglePlayServices()
|
||||
}
|
||||
|
||||
// Request POST_NOTIFICATIONS permission on Android 13+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
lifecycleScope.launch {
|
||||
val launchState = buildLaunchState(intent)
|
||||
applyLaunchState(launchState)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
||||
@@ -1,18 +1,99 @@
|
||||
import UIKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct ComposeView: UIViewControllerRepresentable {
|
||||
let startAtProfileUserId: Int?
|
||||
let startAtProfileUsername: String?
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
MainViewControllerKt.MainViewController()
|
||||
MainViewControllerKt.MainViewController(
|
||||
startAtProfileUserId = startAtProfileUserId,
|
||||
startAtProfileUsername = startAtProfileUsername
|
||||
)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var startAtProfileUserId: Int?
|
||||
@State private var startAtProfileUsername: String?
|
||||
@State private var profileNonce = UUID()
|
||||
@State private var showProfileLinkError = false
|
||||
@State private var profileLinkErrorMessage: String? = nil
|
||||
|
||||
var body: some View {
|
||||
ComposeView()
|
||||
.ignoresSafeArea(.all) // Compose has own keyboard handler
|
||||
ComposeView(
|
||||
startAtProfileUserId: startAtProfileUserId,
|
||||
startAtProfileUsername: startAtProfileUsername
|
||||
)
|
||||
.id(profileNonce)
|
||||
.ignoresSafeArea(.all)
|
||||
.onOpenURL { url in
|
||||
print("[ProfileLink:iOS] onOpenURL url=\(url.absoluteString)")
|
||||
let target = Self.parseProfileDeepLink(url: url)
|
||||
print("[ProfileLink:iOS] parse result userId=\(String(describing: target?.userId)) username=\(String(describing: target?.username)) error=\(String(describing: target?.error))")
|
||||
guard let parsed = target else { return }
|
||||
if let error = parsed.error {
|
||||
profileLinkErrorMessage = error
|
||||
showProfileLinkError = true
|
||||
return
|
||||
}
|
||||
startAtProfileUserId = parsed.userId
|
||||
startAtProfileUsername = parsed.username
|
||||
profileNonce = UUID()
|
||||
}
|
||||
.alert("Profile link", isPresented: $showProfileLinkError) {
|
||||
Button("OK", role: .cancel) {
|
||||
showProfileLinkError = false
|
||||
}
|
||||
} message: {
|
||||
Text(profileLinkErrorMessage ?? "Invalid profile link.")
|
||||
}
|
||||
}
|
||||
|
||||
struct ParsedProfileDeepLink {
|
||||
let userId: Int?
|
||||
let username: String?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
static func parseProfileDeepLink(url: URL) -> ParsedProfileDeepLink? {
|
||||
print("[ProfileLink:iOS] parseProfileDeepLink start url=\(url)")
|
||||
guard url.scheme?.lowercased() == "fromchat",
|
||||
url.host == "u" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let parts = url.path.split(separator: "/").filter { !$0.isEmpty }
|
||||
print("[ProfileLink:iOS] scheme=\(url.scheme ?? "nil") host=\(url.host ?? "nil") path=\(url.path) parts=\(parts.map(String.init))")
|
||||
guard parts.count == 1, let rawSegment = parts.first else {
|
||||
return ParsedProfileDeepLink(
|
||||
userId: nil,
|
||||
username: nil,
|
||||
error: "Invalid profile link. Use fromchat://u/<id-or-username>."
|
||||
)
|
||||
}
|
||||
|
||||
let segment = String(rawSegment).removingPercentEncoding?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !segment.isEmpty else {
|
||||
return ParsedProfileDeepLink(
|
||||
userId: nil,
|
||||
username: nil,
|
||||
error: "Invalid profile link. Use fromchat://u/<id-or-username>."
|
||||
)
|
||||
}
|
||||
print("[ProfileLink:iOS] parsed segment=\(segment)")
|
||||
|
||||
if let id = Int64(segment), id >= 1 && id <= Int64(Int.max) {
|
||||
print("[ProfileLink:iOS] resolved as userId=\(id)")
|
||||
return ParsedProfileDeepLink(userId: Int(id), username: nil, error: nil)
|
||||
}
|
||||
|
||||
print("[ProfileLink:iOS] resolved as username=\(segment)")
|
||||
return ParsedProfileDeepLink(userId: nil, username: segment, error: nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -32,6 +32,19 @@
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>fromchat</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>fromchat</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
|
||||
@@ -50,6 +50,7 @@ kotlin {
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.materials)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
|
||||
// Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.fromchat
|
||||
|
||||
import android.widget.Toast
|
||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||
|
||||
actual fun showProfileLoadErrorMessage(message: String) {
|
||||
Toast.makeText(UtilsLibrary.context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@
|
||||
<string name="cd_send">Отправить</string>
|
||||
<string name="profile_title">Профиль</string>
|
||||
<string name="profile_load_failed">Не получилось загрузить профиль</string>
|
||||
<string name="profile_not_found">Профиль не найден</string>
|
||||
<string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string>
|
||||
<string name="action_open_settings">Настройки</string>
|
||||
<string name="action_chat">Написать</string>
|
||||
<string name="action_copy_link">Скопировать ссылку</string>
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
<!-- Profile -->
|
||||
<string name="profile_title">Profile</string>
|
||||
<string name="profile_load_failed">Couldn’t load this profile</string>
|
||||
<string name="profile_not_found">This profile could not be found</string>
|
||||
<string name="profile_open_failed">Could not open this profile. Please try again.</string>
|
||||
<string name="action_open_settings">Settings</string>
|
||||
<string name="action_chat">Chat</string>
|
||||
<string name="action_copy_link">Copy link</string>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package ru.fromchat
|
||||
|
||||
expect fun showProfileLoadErrorMessage(message: String)
|
||||
|
||||
@@ -307,6 +307,13 @@ object ApiClient {
|
||||
}
|
||||
.body()
|
||||
|
||||
suspend fun getProfileByUsername(username: String): UserProfile =
|
||||
http
|
||||
.get("${Config.apiBaseUrl}/user/$username") {
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
.body()
|
||||
|
||||
suspend fun getRegisteredUserCount(): Int =
|
||||
http
|
||||
.get("${Config.apiBaseUrl}/user/stats/registered-count") {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package ru.fromchat.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import coil3.ImageLoader
|
||||
import coil3.compose.setSingletonImageLoaderFactory
|
||||
import coil3.svg.SvgDecoder
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -18,27 +21,42 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ru.fromchat.AppForeground
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import coil3.ImageLoader
|
||||
import coil3.compose.setSingletonImageLoaderFactory
|
||||
import coil3.svg.SvgDecoder
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.fromchat.AppForeground
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.UserStatusStore
|
||||
import ru.fromchat.api.ProfileCache
|
||||
import ru.fromchat.api.UpdateSyncManager
|
||||
import ru.fromchat.api.UserStatusStore
|
||||
import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.api.WebSocketMessage
|
||||
import ru.fromchat.api.WebSocketUpdatesData
|
||||
import ru.fromchat.net.NetworkConnectivity
|
||||
import ru.fromchat.core.Logger
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.net.NetworkConnectivity
|
||||
import ru.fromchat.ui.auth.LoginScreen
|
||||
import ru.fromchat.ui.auth.RegisterScreen
|
||||
import ru.fromchat.ui.chat.PublicChatScreen
|
||||
@@ -47,20 +65,8 @@ import ru.fromchat.ui.debug.DebugSearchBarDocsScreen
|
||||
import ru.fromchat.ui.dm.DmChatRoute
|
||||
import ru.fromchat.ui.dm.DmNav
|
||||
import ru.fromchat.ui.dm.DmProfileRoute
|
||||
import ru.fromchat.ui.main.MainScreen
|
||||
import ru.fromchat.ui.main.ChatsSearchScreen
|
||||
import ru.fromchat.ui.profile.ProfileScreen
|
||||
import ru.fromchat.ui.setup.ServerConfigScreen
|
||||
import ru.fromchat.ui.LocalSystemBarsVisibility
|
||||
import ru.fromchat.ui.rememberSystemBarsController
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.ui.main.MainScreen
|
||||
import ru.fromchat.ui.main.settings.SettingsAccountScreen
|
||||
import ru.fromchat.ui.main.settings.SettingsAppearanceScreen
|
||||
import ru.fromchat.ui.main.settings.SettingsDevicesScreen
|
||||
@@ -69,7 +75,8 @@ import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||
import ru.fromchat.ui.main.settings.SettingsSecurityHubScreen
|
||||
import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen
|
||||
import ru.fromchat.ui.main.settings.SettingsServerToolsScreen
|
||||
import kotlinx.serialization.json.*
|
||||
import ru.fromchat.ui.profile.ProfileScreen
|
||||
import ru.fromchat.ui.setup.ServerConfigScreen
|
||||
|
||||
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
|
||||
|
||||
@@ -156,7 +163,11 @@ private fun NavGraphBuilder.settingsSlideComposable(
|
||||
fun App(
|
||||
scrollToMessageId: Int? = null,
|
||||
startAtPublicChat: Boolean = false,
|
||||
startAtDmConversationUserId: Int? = null
|
||||
startAtDmConversationUserId: Int? = null,
|
||||
startAtProfileUserId: Int? = null,
|
||||
startAtProfileUsername: String? = null,
|
||||
profileLookupErrorMessage: String? = null,
|
||||
onProfileLookupErrorMessageConsumed: () -> Unit = {}
|
||||
) {
|
||||
setSingletonImageLoaderFactory { context ->
|
||||
ImageLoader.Builder(context)
|
||||
@@ -204,7 +215,7 @@ fun App(
|
||||
}
|
||||
|
||||
// Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]).
|
||||
val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
|
||||
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
fun syncForeground() {
|
||||
AppForeground.setForeground(
|
||||
@@ -242,14 +253,57 @@ fun App(
|
||||
FromChatTheme {
|
||||
SharedTransitionLayout {
|
||||
val navController = rememberNavController()
|
||||
val profileLookupSnackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Handle navigation to the target chat when launched from notification
|
||||
LaunchedEffect(startAtDmConversationUserId, startAtPublicChat, startDestination) {
|
||||
LaunchedEffect(profileLookupErrorMessage) {
|
||||
profileLookupErrorMessage?.let { message ->
|
||||
Logger.d("ProfileDeepLink", "showing snackbar for deep-link lookup failure: $message")
|
||||
profileLookupSnackbarHostState.showSnackbar(
|
||||
message = message,
|
||||
withDismissAction = true,
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
onProfileLookupErrorMessageConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle startup/deep-link navigation targets (notification chat/profile)
|
||||
LaunchedEffect(
|
||||
startAtDmConversationUserId,
|
||||
startAtPublicChat,
|
||||
startAtProfileUserId,
|
||||
startAtProfileUsername,
|
||||
startDestination
|
||||
) {
|
||||
Logger.d(
|
||||
"ProfileDeepLink",
|
||||
"startup nav check: startDestination=$startDestination, startAtProfileUserId=$startAtProfileUserId, " +
|
||||
"startAtProfileUsername=$startAtProfileUsername, startAtDmConversationUserId=$startAtDmConversationUserId, " +
|
||||
"startAtPublicChat=$startAtPublicChat, scrollToMessageId=$scrollToMessageId"
|
||||
)
|
||||
if (startDestination == null || startDestination == "login") {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (startAtDmConversationUserId != null && startAtDmConversationUserId > 0) {
|
||||
if (startAtProfileUserId != null && startAtProfileUserId > 0) {
|
||||
Logger.d(
|
||||
"ProfileDeepLink",
|
||||
"navigating by deep link userId=$startAtProfileUserId"
|
||||
)
|
||||
navController.navigate("profile/$startAtProfileUserId?fromDeepLink=true")
|
||||
} else {
|
||||
val trimmedUsername = startAtProfileUsername?.trim()
|
||||
if (!trimmedUsername.isNullOrBlank()) {
|
||||
Logger.d(
|
||||
"ProfileDeepLink",
|
||||
"navigating by deep link username=$trimmedUsername"
|
||||
)
|
||||
navController.navigate("profile/$trimmedUsername?fromDeepLink=true")
|
||||
} else if (startAtDmConversationUserId != null && startAtDmConversationUserId > 0) {
|
||||
Logger.d(
|
||||
"ProfileDeepLink",
|
||||
"navigating by notification chat route user=$startAtDmConversationUserId messageId=$scrollToMessageId"
|
||||
)
|
||||
navController.navigate(
|
||||
DmNav.chatRoute(
|
||||
otherUserId = startAtDmConversationUserId,
|
||||
@@ -259,16 +313,18 @@ fun App(
|
||||
launchSingleTop = true
|
||||
}
|
||||
} else if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") {
|
||||
Logger.d("ProfileDeepLink", "navigating to public chat route")
|
||||
navController.navigate("chats/publicChat") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up global auth error handler
|
||||
LaunchedEffect(navController) {
|
||||
ApiClient.onAuthError = {
|
||||
ru.fromchat.core.Logger.d("App", "Global auth error handler triggered, navigating to login")
|
||||
Logger.d("App", "Global auth error handler triggered, navigating to login")
|
||||
navController.navigate("login") {
|
||||
popUpTo("chat") { inclusive = true }
|
||||
}
|
||||
@@ -281,7 +337,6 @@ fun App(
|
||||
) {
|
||||
if (startDestination != null) {
|
||||
val animationSpec = tween<IntOffset>(400)
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination!!,
|
||||
@@ -339,7 +394,8 @@ fun App(
|
||||
composable("chat") {
|
||||
MainScreen(
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this
|
||||
animatedVisibilityScope = this,
|
||||
snackbarHostState = profileLookupSnackbarHostState
|
||||
)
|
||||
}
|
||||
|
||||
@@ -384,9 +440,13 @@ fun App(
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "profile/{userId}?useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}",
|
||||
route = "profile/{userId}?fromDeepLink={fromDeepLink}&useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}",
|
||||
arguments = listOf(
|
||||
navArgument("userId") { type = NavType.StringType },
|
||||
navArgument("fromDeepLink") {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
},
|
||||
navArgument("useSharedElement") {
|
||||
type = NavType.StringType
|
||||
defaultValue = "false"
|
||||
@@ -397,18 +457,49 @@ fun App(
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val handle = backStackEntry.savedStateHandle
|
||||
val userId = handle.get<String>("userId")?.toIntOrNull()
|
||||
val useSharedElement = handle.get<String>("useSharedElement") == "true"
|
||||
val sourceMessageId = handle.get<String>("sourceMessageId")?.toIntOrNull() ?: -1
|
||||
val args = backStackEntry.savedStateHandle
|
||||
val userIdParam = args.get<String>("userId")
|
||||
val parsedUserId = userIdParam?.toIntOrNull()
|
||||
val userId = if ((parsedUserId ?: 0) > 0) parsedUserId else null
|
||||
val profileUsername = if (userId == null) {
|
||||
userIdParam?.trim()?.takeIf { it.isNotBlank() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val useSharedElement = when (val rawUseSharedElement = args.get<Any?>("useSharedElement")) {
|
||||
is Boolean -> rawUseSharedElement
|
||||
is String -> rawUseSharedElement == "true"
|
||||
else -> false
|
||||
}
|
||||
val sourceMessageId = when (val rawSourceMessageId = args.get<Any?>("sourceMessageId")) {
|
||||
is Int -> rawSourceMessageId
|
||||
is String -> rawSourceMessageId.toIntOrNull() ?: -1
|
||||
is Long -> rawSourceMessageId.toInt()
|
||||
else -> -1
|
||||
}
|
||||
val fromDeepLink = when (val rawFromDeepLink = args.get<Any?>("fromDeepLink")) {
|
||||
is Boolean -> rawFromDeepLink
|
||||
is String -> rawFromDeepLink == "true"
|
||||
else -> false
|
||||
}
|
||||
|
||||
Logger.d(
|
||||
"ProfileRoute",
|
||||
"profile entry args: rawUserId=$userIdParam parsedUserId=$parsedUserId resolvedUserId=$userId " +
|
||||
"resolvedUsername=$profileUsername sourceMessageId=$sourceMessageId fromDeepLink=$fromDeepLink " +
|
||||
"useSharedElement=$useSharedElement currentRoute=${backStackEntry.destination.route}"
|
||||
)
|
||||
|
||||
ProfileScreen(
|
||||
userId = userId,
|
||||
username = profileUsername,
|
||||
onBack = { navController.navigateUp() },
|
||||
onChat = { navController.navigate(DmNav.chatRoute(it)) },
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this@composable,
|
||||
useSharedElementFromNavigation = useSharedElement,
|
||||
sharedSourceMessageId = sourceMessageId
|
||||
sharedSourceMessageId = sourceMessageId,
|
||||
showErrorAsToast = fromDeepLink
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,11 @@ import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -47,7 +50,9 @@ private const val PAGE_COUNT = 4
|
||||
fun MainScreen(
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
snackbarHostState: SnackbarHostState? = null
|
||||
) {
|
||||
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
|
||||
val navController = LocalNavController.current
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = PAGE_CHATS,
|
||||
@@ -60,6 +65,7 @@ fun MainScreen(
|
||||
val isChatsPage = selectedPage == PAGE_CHATS
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(hostState = effectiveSnackbarHostState) },
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
|
||||
@@ -69,9 +69,11 @@ import com.pr0gramm3r101.components.ListItem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.*
|
||||
import ru.fromchat.core.Logger
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.ProfileCache
|
||||
import ru.fromchat.api.UserProfile
|
||||
@@ -114,6 +116,7 @@ private val profileActionCardPressSpring = spring<Float>(
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
userId: Int?,
|
||||
username: String? = null,
|
||||
onBack: () -> Unit,
|
||||
onChat: (Int) -> Unit,
|
||||
hideAvatar: Boolean = false,
|
||||
@@ -126,6 +129,7 @@ fun ProfileScreen(
|
||||
useSharedElementFromNavigation: Boolean = false,
|
||||
sharedSourceMessageId: Int = -1,
|
||||
initialDisplayName: String? = null,
|
||||
showErrorAsToast: Boolean = false,
|
||||
onOpenSettings: () -> Unit = {}
|
||||
) {
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
@@ -134,6 +138,7 @@ fun ProfileScreen(
|
||||
val profileTitle = stringResource(Res.string.profile_title)
|
||||
val cdBack = stringResource(Res.string.back)
|
||||
val profileLoadFailed = stringResource(Res.string.profile_load_failed)
|
||||
val profileNotFound = stringResource(Res.string.profile_not_found)
|
||||
val labelSettings = stringResource(Res.string.action_open_settings)
|
||||
val labelChat = stringResource(Res.string.action_chat)
|
||||
val labelLink = stringResource(Res.string.action_copy_link)
|
||||
@@ -147,11 +152,27 @@ fun ProfileScreen(
|
||||
val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support)
|
||||
val hideBackButton = navController.currentDestination?.route == "chat"
|
||||
val targetUserId = userId.takeIf { it != null && it > 0 }
|
||||
val targetUsername = username?.trim()?.takeIf { it.isNotBlank() }
|
||||
val ownUserId = ApiClient.user?.id?.takeIf { it > 0 }
|
||||
val cacheLookupId = targetUserId ?: ownUserId
|
||||
val cacheLookupId = when {
|
||||
targetUserId != null -> targetUserId
|
||||
targetUsername != null -> null
|
||||
else -> ownUserId
|
||||
}
|
||||
val isTargetProfileLookup = targetUserId != null || targetUsername != null
|
||||
val lookupMode = if (targetUserId != null) "id" else if (targetUsername != null) "username" else "own"
|
||||
val lookupIdentifier = when {
|
||||
targetUserId != null -> targetUserId.toString()
|
||||
!targetUsername.isNullOrBlank() -> targetUsername
|
||||
else -> "self"
|
||||
}
|
||||
|
||||
var state by remember(targetUserId, ownUserId) {
|
||||
var state by remember(targetUserId, targetUsername, ownUserId) {
|
||||
val cached = cacheLookupId?.let { ProfileCache.get(it) }
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"state init: cacheLookupId=$cacheLookupId cachedId=${cached?.id} cachedUsername=${cached?.username} cachedDisplay=${cached?.displayName}"
|
||||
)
|
||||
mutableStateOf(
|
||||
ProfileUiState(
|
||||
profile = cached,
|
||||
@@ -164,17 +185,25 @@ fun ProfileScreen(
|
||||
val latestUi by rememberUpdatedState(state)
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
LaunchedEffect(cacheLookupId, targetUserId, lifecycleOwner) {
|
||||
LaunchedEffect(cacheLookupId, targetUserId, targetUsername, lifecycleOwner) {
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId " +
|
||||
"lifecycleStarted=${lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)}"
|
||||
)
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
runCatching {
|
||||
if (targetUserId == null) {
|
||||
if (targetUserId == null && targetUsername == null) {
|
||||
ApiClient.getOwnProfile()
|
||||
} else if (targetUsername != null) {
|
||||
ApiClient.getProfileByUsername(targetUsername)
|
||||
} else {
|
||||
ApiClient.getProfileById(targetUserId)
|
||||
ApiClient.getProfileById(targetUserId!!)
|
||||
}
|
||||
}.onSuccess { profile ->
|
||||
if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) {
|
||||
cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) }
|
||||
Logger.d("ProfileScreen", "load success but blank identity for id=${profile.id}, dropping as unusable preview")
|
||||
state = latestUi.copy(
|
||||
profile = null,
|
||||
isLoading = false,
|
||||
@@ -182,16 +211,52 @@ fun ProfileScreen(
|
||||
)
|
||||
return@onSuccess
|
||||
}
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load success: mode=$lookupMode identifier=$lookupIdentifier -> id=${profile.id}, username='${profile.username}', display='${profile.displayName}', deleted=${profile.deleted}, suspended=${profile.suspended}"
|
||||
)
|
||||
ProfileCache.put(profile)
|
||||
state = latestUi.copy(profile = profile, isLoading = false, error = null)
|
||||
}.onFailure { err ->
|
||||
val fallbackId = targetUserId ?: ownUserId
|
||||
val fallbackId = if (targetUsername != null) null else targetUserId ?: ownUserId
|
||||
fallbackId?.let { ProfileCache.evictUnusableClientPreview(it) }
|
||||
val fallback = fallbackId?.let { ProfileCache.get(it) }
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load failure fallback lookup: fallbackId=$fallbackId fallbackFound=${fallback != null}"
|
||||
)
|
||||
val resolvedErrorMessage = when {
|
||||
err is ClientRequestException && err.response.status.value == 404 -> profileNotFound
|
||||
else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed
|
||||
}
|
||||
if (err is ClientRequestException) {
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load failed: mode=$lookupMode identifier=$lookupIdentifier " +
|
||||
"status=${err.response.status.value} fallbackId=$fallbackId error=${err.message}"
|
||||
)
|
||||
} else {
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load failed: mode=$lookupMode identifier=$lookupIdentifier " +
|
||||
"errorType=${err::class.simpleName} message=${err.message}"
|
||||
)
|
||||
}
|
||||
if (
|
||||
showErrorAsToast &&
|
||||
isTargetProfileLookup &&
|
||||
latestUi.profile == null &&
|
||||
fallback == null
|
||||
) {
|
||||
showProfileLoadErrorMessage(resolvedErrorMessage)
|
||||
}
|
||||
state = latestUi.copy(
|
||||
error = if (latestUi.profile == null && fallback == null) {
|
||||
err.message?.takeIf { it.isNotBlank() }?.let { ProfileLoadError.Message(it) }
|
||||
?: ProfileLoadError.Generic
|
||||
if (resolvedErrorMessage == profileLoadFailed) {
|
||||
ProfileLoadError.Generic
|
||||
} else {
|
||||
ProfileLoadError.Message(resolvedErrorMessage)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
|
||||
@@ -4,4 +4,16 @@ import androidx.compose.ui.window.ComposeUIViewController
|
||||
import ru.fromchat.ui.App
|
||||
|
||||
@Suppress("unused")
|
||||
fun MainViewController() = ComposeUIViewController { App() }
|
||||
fun MainViewController(
|
||||
startAtProfileUserId: Int?,
|
||||
startAtProfileUsername: String?
|
||||
) = ComposeUIViewController {
|
||||
App(
|
||||
startAtProfileUserId = startAtProfileUserId,
|
||||
startAtProfileUsername = startAtProfileUsername
|
||||
)
|
||||
}
|
||||
|
||||
fun MainViewController() = ComposeUIViewController {
|
||||
App()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.fromchat
|
||||
|
||||
actual fun showProfileLoadErrorMessage(message: String) {
|
||||
// iOS currently relies on screen-level error UI for deep-link failures.
|
||||
}
|
||||
|
||||
@@ -25,3 +25,4 @@ android.uniquePackageNames=false
|
||||
android.dependency.useConstraints=true
|
||||
android.r8.strictFullModeForKeepRules=false
|
||||
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
|
||||
org.gradle.configuration-cache=true
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "9.1.0"
|
||||
agp = "9.1.1"
|
||||
androidx-activityCompose = "1.13.0"
|
||||
androidx-appcompat = "1.7.1"
|
||||
androidx-core-ktx = "1.18.0"
|
||||
@@ -20,13 +20,13 @@ kotlinxCoroutinesCore = "1.10.2"
|
||||
kotlinxIoCore = "0.9.0"
|
||||
multiplatformSettings = "1.3.0"
|
||||
serialization = "2.3.20"
|
||||
serialization-json = "1.10.0"
|
||||
serialization-json = "1.11.0"
|
||||
material = "1.13.0"
|
||||
activityKtx = "1.13.0"
|
||||
navigationCompose = "2.9.2"
|
||||
datastore = "1.2.1"
|
||||
security-crypto = "1.1.0"
|
||||
ktor = "3.4.1"
|
||||
ktor = "3.4.2"
|
||||
slf4j = "1.7.36"
|
||||
kotlinxDatetime = "0.7.1"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
@@ -37,7 +37,7 @@ composeComponents = "1.10.3"
|
||||
playServicesBase = "18.10.0"
|
||||
tweetnaclJava = "1.1.3"
|
||||
androidxWork = "2.11.2"
|
||||
cryptography-kotlin = "0.5.0"
|
||||
cryptography-kotlin = "0.6.0"
|
||||
krypto = "4.0.10"
|
||||
sqldelight = "2.3.2"
|
||||
|
||||
@@ -46,6 +46,7 @@ androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", versi
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
|
||||
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
|
||||
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coilCompose" }
|
||||
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilCompose" }
|
||||
|
||||
Reference in New Issue
Block a user