Implement deep links

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-04-14 23:41:33 +03:00
Unverified
parent bb391858a9
commit ecfb97907a
19 changed files with 730 additions and 284 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
<component name="ArtifactManager"> <component name="ArtifactManager">
<artifact type="jar" name="shared"> <artifact type="jar" name="shared">
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path> <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> </artifact>
</component> </component>
Generated
+1
View File
@@ -8,5 +8,6 @@
</component> </component>
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/.cursor/skills/material-3-skill" vcs="Git" />
</component> </component>
</project> </project>
+9
View File
@@ -26,6 +26,15 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </activity>
<service <service
android:name=".fcm.FromChatFirebaseMessagingService" android:name=".fcm.FromChatFirebaseMessagingService"
@@ -1,6 +1,7 @@
package ru.fromchat package ru.fromchat
import android.Manifest import android.Manifest
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
@@ -12,6 +13,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.GoogleApiAvailability
import io.ktor.client.call.body 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.client.request.setBody
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.MessagesResponse import ru.fromchat.api.MessagesResponse
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.core.Logger
import ru.fromchat.ui.App import ru.fromchat.ui.App
import ru.fromchat.ui.isPublicChatVisible 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 EXTRA_MESSAGE_ID = "scroll_to_message_id"
private const val CHAT_TYPE_PUBLIC = "public" private const val CHAT_TYPE_PUBLIC = "public"
private const val CHAT_TYPE_DM = "dm" 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() { class MainActivity : ComponentActivity() {
private var scrollToMessageId by mutableStateOf<Int?>(null) private var scrollToMessageId by mutableStateOf<Int?>(null)
private var startAtPublicChat by mutableStateOf(false) private var startAtPublicChat by mutableStateOf(false)
private var startAtDmConversationUserId by mutableStateOf<Int?>(null) 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 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 messageId = intent?.getIntExtra(EXTRA_MESSAGE_ID, -1) ?: -1
val chatType = intent?.getStringExtra(EXTRA_NOTIFICATION_CHAT_TYPE) ?: CHAT_TYPE_PUBLIC val chatType = intent?.getStringExtra(EXTRA_NOTIFICATION_CHAT_TYPE) ?: CHAT_TYPE_PUBLIC
val dmConversationUserId = intent?.getIntExtra(EXTRA_OPEN_DM_USER_ID, -1) ?: -1 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 val baseState = ProfileDeepLinkResolution(
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM scrollToMessageId = if (messageId != -1) messageId else null,
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM,
startAtDmConversationUserId = if (chatType == CHAT_TYPE_DM && dmConversationUserId > 0) { startAtDmConversationUserId = if (chatType == CHAT_TYPE_DM && dmConversationUserId > 0) {
dmConversationUserId dmConversationUserId
} else { } else {
null 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) { if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) {
markMessagesAsRead() 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) @OptIn(DelicateCoroutinesApi::class)
@@ -110,30 +235,38 @@ class MainActivity : ComponentActivity() {
installSplashScreen() installSplashScreen()
enableEdgeToEdge() enableEdgeToEdge()
// Handle initial intent lifecycleScope.launch {
handleIntent(intent) val launchState = buildLaunchState(intent)
applyLaunchState(launchState)
setContent { setContent {
App( App(
scrollToMessageId = scrollToMessageId, scrollToMessageId = scrollToMessageId,
startAtPublicChat = startAtPublicChat, 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() 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) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleIntent(intent) lifecycleScope.launch {
val launchState = buildLaunchState(intent)
applyLaunchState(launchState)
}
} }
override fun onPause() { override fun onPause() {
+84 -3
View File
@@ -1,18 +1,99 @@
import UIKit import UIKit
import Foundation
import SwiftUI import SwiftUI
import ComposeApp import ComposeApp
struct ComposeView: UIViewControllerRepresentable { struct ComposeView: UIViewControllerRepresentable {
let startAtProfileUserId: Int?
let startAtProfileUsername: String?
func makeUIViewController(context: Context) -> UIViewController { func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController() MainViewControllerKt.MainViewController(
startAtProfileUserId = startAtProfileUserId,
startAtProfileUsername = startAtProfileUsername
)
} }
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
} }
struct ContentView: View { 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 { var body: some View {
ComposeView() ComposeView(
.ignoresSafeArea(.all) // Compose has own keyboard handler 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)
}
}
+13
View File
@@ -32,6 +32,19 @@
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
<false/> <false/>
</dict> </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> <key>UILaunchScreen</key>
<dict/> <dict/>
<key>UIRequiredDeviceCapabilities</key> <key>UIRequiredDeviceCapabilities</key>
+1
View File
@@ -50,6 +50,7 @@ kotlin {
implementation(libs.haze) implementation(libs.haze)
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
// Serialization // Serialization
implementation(libs.kotlinx.serialization.json) 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="cd_send">Отправить</string>
<string name="profile_title">Профиль</string> <string name="profile_title">Профиль</string>
<string name="profile_load_failed">Не получилось загрузить профиль</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_open_settings">Настройки</string>
<string name="action_chat">Написать</string> <string name="action_chat">Написать</string>
<string name="action_copy_link">Скопировать ссылку</string> <string name="action_copy_link">Скопировать ссылку</string>
@@ -81,6 +81,8 @@
<!-- Profile --> <!-- Profile -->
<string name="profile_title">Profile</string> <string name="profile_title">Profile</string>
<string name="profile_load_failed">Couldnt load this profile</string> <string name="profile_load_failed">Couldnt 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_open_settings">Settings</string>
<string name="action_chat">Chat</string> <string name="action_chat">Chat</string>
<string name="action_copy_link">Copy link</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() .body()
suspend fun getProfileByUsername(username: String): UserProfile =
http
.get("${Config.apiBaseUrl}/user/$username") {
contentType(ContentType.Application.Json)
}
.body()
suspend fun getRegisteredUserCount(): Int = suspend fun getRegisteredUserCount(): Int =
http http
.get("${Config.apiBaseUrl}/user/stats/registered-count") { .get("${Config.apiBaseUrl}/user/stats/registered-count") {
@@ -1,14 +1,17 @@
package ru.fromchat.ui package ru.fromchat.ui
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import coil3.ImageLoader import androidx.compose.animation.slideInHorizontally
import coil3.compose.setSingletonImageLoaderFactory import androidx.compose.animation.slideOutHorizontally
import coil3.svg.SvgDecoder import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -18,27 +21,42 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import ru.fromchat.AppForeground import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument 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.ApiClient
import ru.fromchat.api.UserStatusStore
import ru.fromchat.api.ProfileCache import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.UserStatusStore
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.net.NetworkConnectivity import ru.fromchat.core.Logger
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen import ru.fromchat.ui.auth.RegisterScreen
import ru.fromchat.ui.chat.PublicChatScreen 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.DmChatRoute
import ru.fromchat.ui.dm.DmNav import ru.fromchat.ui.dm.DmNav
import ru.fromchat.ui.dm.DmProfileRoute import ru.fromchat.ui.dm.DmProfileRoute
import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.main.ChatsSearchScreen import ru.fromchat.ui.main.ChatsSearchScreen
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.main.MainScreen
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.settings.SettingsAccountScreen import ru.fromchat.ui.main.settings.SettingsAccountScreen
import ru.fromchat.ui.main.settings.SettingsAppearanceScreen import ru.fromchat.ui.main.settings.SettingsAppearanceScreen
import ru.fromchat.ui.main.settings.SettingsDevicesScreen 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.SettingsSecurityHubScreen
import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen
import ru.fromchat.ui.main.settings.SettingsServerToolsScreen 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") } val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
@@ -156,7 +163,11 @@ private fun NavGraphBuilder.settingsSlideComposable(
fun App( fun App(
scrollToMessageId: Int? = null, scrollToMessageId: Int? = null,
startAtPublicChat: Boolean = false, startAtPublicChat: Boolean = false,
startAtDmConversationUserId: Int? = null startAtDmConversationUserId: Int? = null,
startAtProfileUserId: Int? = null,
startAtProfileUsername: String? = null,
profileLookupErrorMessage: String? = null,
onProfileLookupErrorMessageConsumed: () -> Unit = {}
) { ) {
setSingletonImageLoaderFactory { context -> setSingletonImageLoaderFactory { context ->
ImageLoader.Builder(context) ImageLoader.Builder(context)
@@ -204,7 +215,7 @@ fun App(
} }
// Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]). // Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]).
val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
fun syncForeground() { fun syncForeground() {
AppForeground.setForeground( AppForeground.setForeground(
@@ -242,14 +253,57 @@ fun App(
FromChatTheme { FromChatTheme {
SharedTransitionLayout { SharedTransitionLayout {
val navController = rememberNavController() val navController = rememberNavController()
val profileLookupSnackbarHostState = remember { SnackbarHostState() }
// Handle navigation to the target chat when launched from notification LaunchedEffect(profileLookupErrorMessage) {
LaunchedEffect(startAtDmConversationUserId, startAtPublicChat, startDestination) { 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") { if (startDestination == null || startDestination == "login") {
return@LaunchedEffect 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( navController.navigate(
DmNav.chatRoute( DmNav.chatRoute(
otherUserId = startAtDmConversationUserId, otherUserId = startAtDmConversationUserId,
@@ -259,16 +313,18 @@ fun App(
launchSingleTop = true launchSingleTop = true
} }
} else if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") { } else if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") {
Logger.d("ProfileDeepLink", "navigating to public chat route")
navController.navigate("chats/publicChat") { navController.navigate("chats/publicChat") {
launchSingleTop = true launchSingleTop = true
} }
} }
} }
}
// Set up global auth error handler // Set up global auth error handler
LaunchedEffect(navController) { LaunchedEffect(navController) {
ApiClient.onAuthError = { 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") { navController.navigate("login") {
popUpTo("chat") { inclusive = true } popUpTo("chat") { inclusive = true }
} }
@@ -281,7 +337,6 @@ fun App(
) { ) {
if (startDestination != null) { if (startDestination != null) {
val animationSpec = tween<IntOffset>(400) val animationSpec = tween<IntOffset>(400)
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination!!, startDestination = startDestination!!,
@@ -339,7 +394,8 @@ fun App(
composable("chat") { composable("chat") {
MainScreen( MainScreen(
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this animatedVisibilityScope = this,
snackbarHostState = profileLookupSnackbarHostState
) )
} }
@@ -384,9 +440,13 @@ fun App(
} }
composable( composable(
route = "profile/{userId}?useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}", route = "profile/{userId}?fromDeepLink={fromDeepLink}&useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}",
arguments = listOf( arguments = listOf(
navArgument("userId") { type = NavType.StringType }, navArgument("userId") { type = NavType.StringType },
navArgument("fromDeepLink") {
type = NavType.BoolType
defaultValue = false
},
navArgument("useSharedElement") { navArgument("useSharedElement") {
type = NavType.StringType type = NavType.StringType
defaultValue = "false" defaultValue = "false"
@@ -397,18 +457,49 @@ fun App(
} }
) )
) { backStackEntry -> ) { backStackEntry ->
val handle = backStackEntry.savedStateHandle val args = backStackEntry.savedStateHandle
val userId = handle.get<String>("userId")?.toIntOrNull() val userIdParam = args.get<String>("userId")
val useSharedElement = handle.get<String>("useSharedElement") == "true" val parsedUserId = userIdParam?.toIntOrNull()
val sourceMessageId = handle.get<String>("sourceMessageId")?.toIntOrNull() ?: -1 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( ProfileScreen(
userId = userId, userId = userId,
username = profileUsername,
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
onChat = { navController.navigate(DmNav.chatRoute(it)) }, onChat = { navController.navigate(DmNav.chatRoute(it)) },
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable, animatedVisibilityScope = this@composable,
useSharedElementFromNavigation = useSharedElement, 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.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -47,7 +50,9 @@ private const val PAGE_COUNT = 4
fun MainScreen( fun MainScreen(
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
snackbarHostState: SnackbarHostState? = null
) { ) {
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
val navController = LocalNavController.current val navController = LocalNavController.current
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
initialPage = PAGE_CHATS, initialPage = PAGE_CHATS,
@@ -60,6 +65,7 @@ fun MainScreen(
val isChatsPage = selectedPage == PAGE_CHATS val isChatsPage = selectedPage == PAGE_CHATS
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(hostState = effectiveSnackbarHostState) },
bottomBar = { bottomBar = {
NavigationBar { NavigationBar {
NavigationBarItem( NavigationBarItem(
@@ -69,9 +69,11 @@ import com.pr0gramm3r101.components.ListItem
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import io.ktor.client.plugins.ClientRequestException
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.* import ru.fromchat.*
import ru.fromchat.core.Logger
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UserProfile import ru.fromchat.api.UserProfile
@@ -114,6 +116,7 @@ private val profileActionCardPressSpring = spring<Float>(
@Composable @Composable
fun ProfileScreen( fun ProfileScreen(
userId: Int?, userId: Int?,
username: String? = null,
onBack: () -> Unit, onBack: () -> Unit,
onChat: (Int) -> Unit, onChat: (Int) -> Unit,
hideAvatar: Boolean = false, hideAvatar: Boolean = false,
@@ -126,6 +129,7 @@ fun ProfileScreen(
useSharedElementFromNavigation: Boolean = false, useSharedElementFromNavigation: Boolean = false,
sharedSourceMessageId: Int = -1, sharedSourceMessageId: Int = -1,
initialDisplayName: String? = null, initialDisplayName: String? = null,
showErrorAsToast: Boolean = false,
onOpenSettings: () -> Unit = {} onOpenSettings: () -> Unit = {}
) { ) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current val clipboardManager: ClipboardManager = LocalClipboardManager.current
@@ -134,6 +138,7 @@ fun ProfileScreen(
val profileTitle = stringResource(Res.string.profile_title) val profileTitle = stringResource(Res.string.profile_title)
val cdBack = stringResource(Res.string.back) val cdBack = stringResource(Res.string.back)
val profileLoadFailed = stringResource(Res.string.profile_load_failed) 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 labelSettings = stringResource(Res.string.action_open_settings)
val labelChat = stringResource(Res.string.action_chat) val labelChat = stringResource(Res.string.action_chat)
val labelLink = stringResource(Res.string.action_copy_link) val labelLink = stringResource(Res.string.action_copy_link)
@@ -147,11 +152,27 @@ fun ProfileScreen(
val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support) val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support)
val hideBackButton = navController.currentDestination?.route == "chat" val hideBackButton = navController.currentDestination?.route == "chat"
val targetUserId = userId.takeIf { it != null && it > 0 } val targetUserId = userId.takeIf { it != null && it > 0 }
val targetUsername = username?.trim()?.takeIf { it.isNotBlank() }
val ownUserId = ApiClient.user?.id?.takeIf { it > 0 } 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) } val cached = cacheLookupId?.let { ProfileCache.get(it) }
Logger.d(
"ProfileScreen",
"state init: cacheLookupId=$cacheLookupId cachedId=${cached?.id} cachedUsername=${cached?.username} cachedDisplay=${cached?.displayName}"
)
mutableStateOf( mutableStateOf(
ProfileUiState( ProfileUiState(
profile = cached, profile = cached,
@@ -164,17 +185,25 @@ fun ProfileScreen(
val latestUi by rememberUpdatedState(state) val latestUi by rememberUpdatedState(state)
val lifecycleOwner = LocalLifecycleOwner.current 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) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
runCatching { runCatching {
if (targetUserId == null) { if (targetUserId == null && targetUsername == null) {
ApiClient.getOwnProfile() ApiClient.getOwnProfile()
} else if (targetUsername != null) {
ApiClient.getProfileByUsername(targetUsername)
} else { } else {
ApiClient.getProfileById(targetUserId) ApiClient.getProfileById(targetUserId!!)
} }
}.onSuccess { profile -> }.onSuccess { profile ->
if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) { if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) {
cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) } cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) }
Logger.d("ProfileScreen", "load success but blank identity for id=${profile.id}, dropping as unusable preview")
state = latestUi.copy( state = latestUi.copy(
profile = null, profile = null,
isLoading = false, isLoading = false,
@@ -182,16 +211,52 @@ fun ProfileScreen(
) )
return@onSuccess 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) ProfileCache.put(profile)
state = latestUi.copy(profile = profile, isLoading = false, error = null) state = latestUi.copy(profile = profile, isLoading = false, error = null)
}.onFailure { err -> }.onFailure { err ->
val fallbackId = targetUserId ?: ownUserId val fallbackId = if (targetUsername != null) null else targetUserId ?: ownUserId
fallbackId?.let { ProfileCache.evictUnusableClientPreview(it) } fallbackId?.let { ProfileCache.evictUnusableClientPreview(it) }
val fallback = fallbackId?.let { ProfileCache.get(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( state = latestUi.copy(
error = if (latestUi.profile == null && fallback == null) { error = if (latestUi.profile == null && fallback == null) {
err.message?.takeIf { it.isNotBlank() }?.let { ProfileLoadError.Message(it) } if (resolvedErrorMessage == profileLoadFailed) {
?: ProfileLoadError.Generic ProfileLoadError.Generic
} else {
ProfileLoadError.Message(resolvedErrorMessage)
}
} else { } else {
null null
}, },
@@ -4,4 +4,16 @@ import androidx.compose.ui.window.ComposeUIViewController
import ru.fromchat.ui.App import ru.fromchat.ui.App
@Suppress("unused") @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.
}
+1
View File
@@ -25,3 +25,4 @@ android.uniquePackageNames=false
android.dependency.useConstraints=true android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false android.r8.strictFullModeForKeepRules=false
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
org.gradle.configuration-cache=true
+5 -4
View File
@@ -1,5 +1,5 @@
[versions] [versions]
agp = "9.1.0" agp = "9.1.1"
androidx-activityCompose = "1.13.0" androidx-activityCompose = "1.13.0"
androidx-appcompat = "1.7.1" androidx-appcompat = "1.7.1"
androidx-core-ktx = "1.18.0" androidx-core-ktx = "1.18.0"
@@ -20,13 +20,13 @@ kotlinxCoroutinesCore = "1.10.2"
kotlinxIoCore = "0.9.0" kotlinxIoCore = "0.9.0"
multiplatformSettings = "1.3.0" multiplatformSettings = "1.3.0"
serialization = "2.3.20" serialization = "2.3.20"
serialization-json = "1.10.0" serialization-json = "1.11.0"
material = "1.13.0" material = "1.13.0"
activityKtx = "1.13.0" activityKtx = "1.13.0"
navigationCompose = "2.9.2" navigationCompose = "2.9.2"
datastore = "1.2.1" datastore = "1.2.1"
security-crypto = "1.1.0" security-crypto = "1.1.0"
ktor = "3.4.1" ktor = "3.4.2"
slf4j = "1.7.36" slf4j = "1.7.36"
kotlinxDatetime = "0.7.1" kotlinxDatetime = "0.7.1"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
@@ -37,7 +37,7 @@ composeComponents = "1.10.3"
playServicesBase = "18.10.0" playServicesBase = "18.10.0"
tweetnaclJava = "1.1.3" tweetnaclJava = "1.1.3"
androidxWork = "2.11.2" androidxWork = "2.11.2"
cryptography-kotlin = "0.5.0" cryptography-kotlin = "0.6.0"
krypto = "4.0.10" krypto = "4.0.10"
sqldelight = "2.3.2" 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-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-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } 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-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-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" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilCompose" }