Прочтение
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -117,6 +117,12 @@ dependencies {
|
|||||||
implementation("com.google.firebase:firebase-messaging-ktx")
|
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||||
implementation("com.google.firebase:firebase-analytics-ktx")
|
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||||
|
|
||||||
|
// Room
|
||||||
|
val room_version = "2.6.1"
|
||||||
|
implementation("androidx.room:room-runtime:$room_version")
|
||||||
|
implementation("androidx.room:room-ktx:$room_version")
|
||||||
|
kapt("androidx.room:room-compiler:$room_version")
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ import core.presentation.theme.ForkMessengerTheme
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Request notifications permission for Android 13+
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
androidx.core.app.ActivityCompat.requestPermissions(
|
||||||
|
this,
|
||||||
|
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
101
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
ForkMessengerTheme {
|
ForkMessengerTheme {
|
||||||
Surface(
|
Surface(
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ class AuthViewModel @Inject constructor(
|
|||||||
private val repository: AuthRepository
|
private val repository: AuthRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (repository.isAuthenticated()) {
|
||||||
|
updatePushToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
|
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
|
||||||
val state: StateFlow<AuthState> = _state.asStateFlow()
|
val state: StateFlow<AuthState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
data class ReadMessagesRequest(
|
||||||
|
val chatId: String,
|
||||||
|
val lastReadMessageId: String,
|
||||||
|
val lastReadSequenceId: Int
|
||||||
|
)
|
||||||
|
|
||||||
enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
|
enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -38,7 +44,7 @@ class ChatHubClient @Inject constructor() {
|
|||||||
lastToken = accessToken
|
lastToken = accessToken
|
||||||
_status.value = ConnectionStatus.CONNECTING
|
_status.value = ConnectionStatus.CONNECTING
|
||||||
|
|
||||||
hubConnection = HubConnectionBuilder.create("${baseUrl}/chatHub")
|
hubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat")
|
||||||
.withAccessTokenProvider(Single.just(accessToken))
|
.withAccessTokenProvider(Single.just(accessToken))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -87,8 +93,13 @@ class ChatHubClient @Inject constructor() {
|
|||||||
_events.tryEmit(ChatEvent.NewChat(chat))
|
_events.tryEmit(ChatEvent.NewChat(chat))
|
||||||
}, ChatDto::class.java)
|
}, ChatDto::class.java)
|
||||||
|
|
||||||
conn.on("reaction_updated", { messageId: String, chatId: String, userId: String, emoji: String ->
|
conn.on("reaction_added", { messageId: String, chatId: String, userId: String, username: String, emoji: String ->
|
||||||
_events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, emoji))
|
_events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, emoji))
|
||||||
|
}, String::class.java, String::class.java, String::class.java, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("reaction_removed", { messageId: String, chatId: String, userId: String, emoji: String ->
|
||||||
|
// Using ReactionUpdated with empty emoji to signal removal or just a specific removal event
|
||||||
|
_events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, ""))
|
||||||
}, String::class.java, String::class.java, String::class.java, String::class.java)
|
}, String::class.java, String::class.java, String::class.java, String::class.java)
|
||||||
|
|
||||||
// WebRTC Signaling Handlers
|
// WebRTC Signaling Handlers
|
||||||
@@ -114,4 +125,10 @@ class ChatHubClient @Inject constructor() {
|
|||||||
hubConnection?.stop()
|
hubConnection?.stop()
|
||||||
_status.value = ConnectionStatus.DISCONNECTED
|
_status.value = ConnectionStatus.DISCONNECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun readMessages(request: ReadMessagesRequest) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("read_messages", request)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import chats.data.remote.api.ChatApi
|
|||||||
import chats.data.remote.api.SendMessageRequest
|
import chats.data.remote.api.SendMessageRequest
|
||||||
import chats.data.remote.dto.ChatDto
|
import chats.data.remote.dto.ChatDto
|
||||||
import chats.data.remote.dto.MessageDto
|
import chats.data.remote.dto.MessageDto
|
||||||
|
import chats.data.remote.dto.MediaItemDto
|
||||||
|
import chats.data.remote.dto.ReactionDto
|
||||||
import chats.domain.model.Chat
|
import chats.domain.model.Chat
|
||||||
import chats.domain.model.Message
|
import chats.domain.model.Message
|
||||||
import chats.domain.model.MediaType
|
import chats.domain.model.MediaType
|
||||||
@@ -14,12 +16,16 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
class ChatRepositoryImpl @Inject constructor(
|
class ChatRepositoryImpl @Inject constructor(
|
||||||
private val api: ChatApi,
|
private val api: ChatApi,
|
||||||
private val tokenManager: TokenManager,
|
private val tokenManager: TokenManager,
|
||||||
private val serverConfig: ServerConfig
|
private val serverConfig: ServerConfig,
|
||||||
|
private val messageDao: core.database.data.MessageDao,
|
||||||
|
private val hubClient: chats.data.remote.signalr.ChatHubClient
|
||||||
) : ChatRepository {
|
) : ChatRepository {
|
||||||
|
private val gson = com.google.gson.Gson()
|
||||||
|
|
||||||
override suspend fun getChats(): List<Chat> {
|
override suspend fun getChats(): List<Chat> {
|
||||||
val currentUserId = tokenManager.getUserId() ?: ""
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
@@ -27,9 +33,24 @@ class ChatRepositoryImpl @Inject constructor(
|
|||||||
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
|
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
return messageDao.getMessages(chatId).map { entities: List<core.database.data.MessageEntity> ->
|
||||||
|
entities.map { it.toDomain(baseUrl, gson) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getMessages(chatId: String): List<Message> {
|
override suspend fun getMessages(chatId: String): List<Message> {
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
return api.getMessages(chatId).map { it.toDomain(baseUrl) }
|
return try {
|
||||||
|
val messages = api.getMessages(chatId)
|
||||||
|
// Save to DB
|
||||||
|
messageDao.insertMessages(messages.map { msg: MessageDto -> msg.toEntity(baseUrl, gson) })
|
||||||
|
messages.map { it.toDomain(baseUrl) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// If network fails, caller should ideally use the Flow from DB
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendMessage(
|
override suspend fun sendMessage(
|
||||||
@@ -55,8 +76,21 @@ class ChatRepositoryImpl @Inject constructor(
|
|||||||
api.sendTypingStatus(chatId)
|
api.sendTypingStatus(chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) {
|
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
||||||
api.markMessagesAsRead(chatId, lastMessageId)
|
val request = chats.data.remote.signalr.ReadMessagesRequest(
|
||||||
|
chatId = chatId,
|
||||||
|
lastReadMessageId = lastMessageId,
|
||||||
|
lastReadSequenceId = lastReadSequenceId
|
||||||
|
)
|
||||||
|
hubClient.readMessages(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveMessage(message: Message) {
|
||||||
|
messageDao.insertMessages(listOf(message.toEntity(gson)))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteLocalMessage(messageId: String) {
|
||||||
|
messageDao.deleteMessage(messageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun uploadMedia(file: java.io.File): String {
|
override suspend fun uploadMedia(file: java.io.File): String {
|
||||||
@@ -98,6 +132,24 @@ fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
||||||
|
return core.database.data.MessageEntity(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = senderId,
|
||||||
|
senderName = senderName,
|
||||||
|
senderAvatar = senderAvatar,
|
||||||
|
content = content,
|
||||||
|
sequenceId = sequenceId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
mediaType = mediaType.name.lowercase(),
|
||||||
|
mediaJson = gson.toJson(media),
|
||||||
|
reactionsJson = gson.toJson(reactions),
|
||||||
|
isRead = isRead,
|
||||||
|
replyToId = replyTo?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun MessageDto.toDomain(baseUrl: String): Message {
|
fun MessageDto.toDomain(baseUrl: String): Message {
|
||||||
val domainMediaType = when (type) {
|
val domainMediaType = when (type) {
|
||||||
"gif" -> MediaType.GIF
|
"gif" -> MediaType.GIF
|
||||||
@@ -135,10 +187,70 @@ fun MessageDto.toDomain(baseUrl: String): Message {
|
|||||||
},
|
},
|
||||||
mediaType = domainMediaType,
|
mediaType = domainMediaType,
|
||||||
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(),
|
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(),
|
||||||
|
isRead = true, // Network messages are usually considered read when fetched or handled by server
|
||||||
replyTo = replyTo?.toDomain(baseUrl)
|
replyTo = replyTo?.toDomain(baseUrl)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun MessageDto.toEntity(baseUrl: String, gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
||||||
|
return core.database.data.MessageEntity(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId ?: "",
|
||||||
|
senderId = senderId ?: "",
|
||||||
|
senderName = sender?.displayName ?: "Unknown",
|
||||||
|
senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl),
|
||||||
|
content = content,
|
||||||
|
sequenceId = sequenceId ?: 0,
|
||||||
|
createdAt = createdAt ?: "",
|
||||||
|
mediaType = type ?: "text",
|
||||||
|
mediaJson = gson.toJson(media),
|
||||||
|
reactionsJson = gson.toJson(reactions),
|
||||||
|
isRead = true,
|
||||||
|
replyToId = replyTo?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun core.database.data.MessageEntity.toDomain(baseUrl: String, gson: com.google.gson.Gson): Message {
|
||||||
|
val mediaTypeEnum = when (mediaType) {
|
||||||
|
"gif" -> MediaType.GIF
|
||||||
|
"image", "photo" -> MediaType.IMAGE
|
||||||
|
"video" -> MediaType.VIDEO
|
||||||
|
"audio", "voice" -> MediaType.AUDIO
|
||||||
|
"file" -> MediaType.FILE
|
||||||
|
else -> MediaType.TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.MediaItemDto>>() {}.type
|
||||||
|
val mediaDtos: List<chats.data.remote.dto.MediaItemDto> = gson.fromJson(mediaJson, mediaTypeToken) ?: emptyList()
|
||||||
|
|
||||||
|
val reactionsTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.ReactionDto>>() {}.type
|
||||||
|
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = gson.fromJson(reactionsJson, reactionsTypeToken) ?: emptyList()
|
||||||
|
|
||||||
|
return Message(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = senderId,
|
||||||
|
senderName = senderName,
|
||||||
|
senderAvatar = senderAvatar,
|
||||||
|
content = content,
|
||||||
|
sequenceId = sequenceId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
media = mediaDtos.map {
|
||||||
|
chats.domain.model.Media(
|
||||||
|
id = it.id,
|
||||||
|
type = it.type,
|
||||||
|
url = it.url.ensureAbsoluteUrl(baseUrl),
|
||||||
|
filename = it.filename,
|
||||||
|
size = it.size,
|
||||||
|
duration = it.duration
|
||||||
|
)
|
||||||
|
},
|
||||||
|
mediaType = mediaTypeEnum,
|
||||||
|
reactions = reactionDtos.associate { it.emoji to it.count },
|
||||||
|
isRead = isRead
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun String.ensureAbsoluteUrl(baseUrl: String): String {
|
fun String.ensureAbsoluteUrl(baseUrl: String): String {
|
||||||
return if (this.startsWith("http")) {
|
return if (this.startsWith("http")) {
|
||||||
this
|
this
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import chats.data.remote.api.ChatApi
|
|||||||
import chats.data.remote.signalr.ChatHubClient
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
import chats.data.repository.ChatRepositoryImpl
|
import chats.data.repository.ChatRepositoryImpl
|
||||||
import chats.domain.repository.ChatRepository
|
import chats.domain.repository.ChatRepository
|
||||||
|
import core.database.data.MessageDao
|
||||||
import core.network.ServerConfig
|
import core.network.ServerConfig
|
||||||
import core.security.TokenManager
|
import core.security.TokenManager
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
@@ -25,8 +26,14 @@ object ChatModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideChatRepository(api: ChatApi, tokenManager: TokenManager, serverConfig: ServerConfig): ChatRepository {
|
fun provideChatRepository(
|
||||||
return ChatRepositoryImpl(api, tokenManager, serverConfig)
|
api: ChatApi,
|
||||||
|
tokenManager: TokenManager,
|
||||||
|
serverConfig: ServerConfig,
|
||||||
|
messageDao: MessageDao,
|
||||||
|
hubClient: chats.data.remote.signalr.ChatHubClient
|
||||||
|
): ChatRepository {
|
||||||
|
return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, hubClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import chats.domain.model.Message
|
|||||||
|
|
||||||
interface ChatRepository {
|
interface ChatRepository {
|
||||||
suspend fun getChats(): List<Chat>
|
suspend fun getChats(): List<Chat>
|
||||||
|
fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>>
|
||||||
suspend fun getMessages(chatId: String): List<Message>
|
suspend fun getMessages(chatId: String): List<Message>
|
||||||
suspend fun sendMessage(
|
suspend fun sendMessage(
|
||||||
chatId: String,
|
chatId: String,
|
||||||
@@ -14,7 +15,9 @@ interface ChatRepository {
|
|||||||
): Message
|
): Message
|
||||||
suspend fun addReaction(messageId: String, emoji: String)
|
suspend fun addReaction(messageId: String, emoji: String)
|
||||||
suspend fun sendTypingStatus(chatId: String)
|
suspend fun sendTypingStatus(chatId: String)
|
||||||
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String)
|
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int)
|
||||||
|
suspend fun saveMessage(message: Message)
|
||||||
|
suspend fun deleteLocalMessage(messageId: String)
|
||||||
suspend fun uploadMedia(file: java.io.File): String
|
suspend fun uploadMedia(file: java.io.File): String
|
||||||
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
||||||
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chats.presentation.components.MediaPicker
|
import chats.presentation.components.MediaPicker
|
||||||
import chats.presentation.components.MessageBubble
|
import chats.presentation.components.MessageBubble
|
||||||
@@ -28,7 +31,7 @@ import java.io.File
|
|||||||
import ru.knot.messager.R
|
import ru.knot.messager.R
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatDetailScreen(
|
fun ChatDetailScreen(
|
||||||
chatId: String,
|
chatId: String,
|
||||||
@@ -45,6 +48,9 @@ fun ChatDetailScreen(
|
|||||||
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
||||||
var isRecording by remember { mutableStateOf(false) }
|
var isRecording by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
var autoPlayingMessageId by remember { mutableStateOf<String?>(null) }
|
var autoPlayingMessageId by remember { mutableStateOf<String?>(null) }
|
||||||
var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) }
|
var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||||
|
|
||||||
@@ -89,11 +95,28 @@ fun ChatDetailScreen(
|
|||||||
viewModel.setChatId(chatId)
|
viewModel.setChatId(chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Автопрокрутка к последнему сообщению
|
// Автопрокрутка к первому непрочитанному или к самому низу
|
||||||
|
LaunchedEffect(state.initialScrollIndex) {
|
||||||
|
state.initialScrollIndex?.let { index ->
|
||||||
|
if (index < state.messages.size) {
|
||||||
|
listState.scrollToItem(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автопрокрутка к новому сообщению, если мы внизу
|
||||||
LaunchedEffect(state.messages.size) {
|
LaunchedEffect(state.messages.size) {
|
||||||
if (state.messages.isNotEmpty()) {
|
if (state.messages.isNotEmpty()) {
|
||||||
|
val isAtBottom = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.messages.size - 2
|
||||||
|
if (isAtBottom || state.initialScrollIndex == null) {
|
||||||
listState.animateScrollToItem(state.messages.size - 1)
|
listState.animateScrollToItem(state.messages.size - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если пришли новые сообщения и мы их видим — помечаем как прочитанные
|
||||||
|
if (isAtBottom) {
|
||||||
|
viewModel.markAsRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -141,6 +164,7 @@ fun ChatDetailScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
|
.imePadding()
|
||||||
) {
|
) {
|
||||||
// Список сообщений
|
// Список сообщений
|
||||||
Box(
|
Box(
|
||||||
@@ -193,7 +217,15 @@ fun ChatDetailScreen(
|
|||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = { isEmojiPickerVisible = !isEmojiPickerVisible }) {
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
isEmojiPickerVisible = !isEmojiPickerVisible
|
||||||
|
if (isEmojiPickerVisible) {
|
||||||
|
keyboardController?.hide()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.EmojiEmotions,
|
Icons.Default.EmojiEmotions,
|
||||||
contentDescription = stringResource(R.string.emoji),
|
contentDescription = stringResource(R.string.emoji),
|
||||||
@@ -207,7 +239,13 @@ fun ChatDetailScreen(
|
|||||||
TextField(
|
TextField(
|
||||||
value = textInput,
|
value = textInput,
|
||||||
onValueChange = { textInput = it },
|
onValueChange = { textInput = it },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
isEmojiPickerVisible = false
|
||||||
|
}
|
||||||
|
},
|
||||||
placeholder = { Text(stringResource(R.string.message_placeholder)) },
|
placeholder = { Text(stringResource(R.string.message_placeholder)) },
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
colors = TextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ data class ChatDetailState(
|
|||||||
val searchedGifs: List<KlipyGifDto> = emptyList(),
|
val searchedGifs: List<KlipyGifDto> = emptyList(),
|
||||||
val recentGifs: List<KlipyGifDto> = emptyList(),
|
val recentGifs: List<KlipyGifDto> = emptyList(),
|
||||||
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
|
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
|
||||||
val isGifsLoading: Boolean = false
|
val isGifsLoading: Boolean = false,
|
||||||
|
val initialScrollIndex: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -69,13 +70,39 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
// Ensure SignalR is connected
|
// Ensure SignalR is connected
|
||||||
val token = tokenManager.getToken()
|
val token = tokenManager.getToken()
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api")
|
||||||
signalrClient.connect(baseUrl, token)
|
signalrClient.connect(baseUrl, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadChatInfo(chatId)
|
loadChatInfo(chatId)
|
||||||
loadMessages(chatId)
|
observeMessages(chatId)
|
||||||
observeSignalREvents()
|
observeSignalREvents()
|
||||||
|
|
||||||
|
// Initial sync from network
|
||||||
|
refreshMessages(chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeMessages(chatId: String) {
|
||||||
|
repository.getMessagesFlow(chatId)
|
||||||
|
.onEach { messages ->
|
||||||
|
_state.update { it.copy(messages = messages) }
|
||||||
|
// Calculate scroll index if not set
|
||||||
|
if (_state.value.initialScrollIndex == null && messages.isNotEmpty()) {
|
||||||
|
val firstUnreadIndex = messages.indexOfFirst { !it.isRead && it.senderId != getCurrentUserId() }
|
||||||
|
val targetIndex = if (firstUnreadIndex != -1) firstUnreadIndex else messages.size - 1
|
||||||
|
_state.update { it.copy(initialScrollIndex = targetIndex) }
|
||||||
|
|
||||||
|
// Automark as read
|
||||||
|
markAsRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshMessages(chatId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.getMessages(chatId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadChatInfo(chatId: String) {
|
private fun loadChatInfo(chatId: String) {
|
||||||
@@ -92,17 +119,16 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMessages(chatId: String) {
|
private fun updateMessageReaction(messageId: String, userId: String, emoji: String) {
|
||||||
viewModelScope.launch {
|
_state.update { s ->
|
||||||
_state.update { it.copy(isLoading = true) }
|
val updatedMessages = s.messages.map { msg ->
|
||||||
try {
|
if (msg.id == messageId) {
|
||||||
// Бэкенд обычно возвращает сообщения от новых к старым.
|
val currentReactions = msg.reactions.toMutableMap()
|
||||||
// Для чата нам нужно наоборот: старые вверху, новые внизу.
|
currentReactions[emoji] = (currentReactions[emoji] ?: 0) + 1
|
||||||
val messages = repository.getMessages(chatId).reversed()
|
msg.copy(reactions = currentReactions)
|
||||||
_state.update { it.copy(messages = messages, isLoading = false) }
|
} else msg
|
||||||
} catch (e: Exception) {
|
|
||||||
_state.update { it.copy(isLoading = false, error = e.localizedMessage) }
|
|
||||||
}
|
}
|
||||||
|
s.copy(messages = updatedMessages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,13 +145,10 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
.onEach { event ->
|
.onEach { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is ChatEvent.NewMessage -> {
|
is ChatEvent.NewMessage -> {
|
||||||
// Avoid adding duplicates if already loaded
|
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
_state.update { s ->
|
|
||||||
val domainMsg = event.message.toDomain(baseUrl)
|
val domainMsg = event.message.toDomain(baseUrl)
|
||||||
if (s.messages.none { it.id == domainMsg.id }) {
|
viewModelScope.launch {
|
||||||
s.copy(messages = s.messages + domainMsg)
|
repository.saveMessage(domainMsg)
|
||||||
} else s
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is ChatEvent.ReactionUpdated -> {
|
is ChatEvent.ReactionUpdated -> {
|
||||||
@@ -142,39 +165,53 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
.launchIn(viewModelScope)
|
.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateMessageReaction(messageId: String, userId: String, emoji: String) {
|
fun markAsRead() {
|
||||||
_state.update { s ->
|
val chatId = currentChatId ?: return
|
||||||
val updatedMessages = s.messages.map { msg ->
|
val messages = _state.value.messages
|
||||||
if (msg.id == messageId) {
|
if (messages.isEmpty()) return
|
||||||
// Logic to update reactions map.
|
|
||||||
// Note: Simplified logic, usually we need to know if it was added or removed.
|
|
||||||
// If we assume reaction_updated is a toggle:
|
|
||||||
val currentReactions = msg.reactions.toMutableMap()
|
|
||||||
val count = currentReactions[emoji] ?: 0
|
|
||||||
// This is a placeholder logic as the exact behavior depends on server implementation.
|
|
||||||
// For now, let's just increment/decrement based on some convention or just refresh.
|
|
||||||
currentReactions[emoji] = count + 1
|
|
||||||
msg.copy(reactions = currentReactions)
|
|
||||||
} else msg
|
|
||||||
}
|
|
||||||
s.copy(messages = updatedMessages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
val lastMessage = messages.last()
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.markMessagesAsRead(chatId, lastMessage.id, lastMessage.sequenceId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
fun sendMessage(text: String) {
|
fun sendMessage(text: String) {
|
||||||
val chatId = currentChatId ?: return
|
val chatId = currentChatId ?: return
|
||||||
if (text.isBlank()) return
|
if (text.isBlank()) return
|
||||||
|
|
||||||
|
val tempId = "temp_${System.currentTimeMillis()}"
|
||||||
|
val userId = getCurrentUserId()
|
||||||
|
|
||||||
|
val tempMessage = Message(
|
||||||
|
id = tempId,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = userId,
|
||||||
|
content = text,
|
||||||
|
createdAt = java.util.Date().toString(),
|
||||||
|
mediaType = chats.domain.model.MediaType.TEXT,
|
||||||
|
media = emptyList(),
|
||||||
|
senderName = "Вы",
|
||||||
|
senderAvatar = null,
|
||||||
|
reactions = emptyMap(),
|
||||||
|
isRead = false,
|
||||||
|
sequenceId = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.saveMessage(tempMessage)
|
||||||
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val sentMessage = repository.sendMessage(chatId, text)
|
val sentMessage = repository.sendMessage(chatId, text)
|
||||||
// Optimistic update if not already there
|
repository.deleteLocalMessage(tempId)
|
||||||
_state.update { s ->
|
repository.saveMessage(sentMessage)
|
||||||
if (s.messages.none { it.id == sentMessage.id }) {
|
|
||||||
s.copy(messages = s.messages + sentMessage)
|
|
||||||
} else s
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
repository.deleteLocalMessage(tempId)
|
||||||
_state.update { it.copy(error = e.localizedMessage) }
|
_state.update { it.copy(error = e.localizedMessage) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,6 +290,28 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun sendGif(url: String) {
|
fun sendGif(url: String) {
|
||||||
val chatId = currentChatId ?: return
|
val chatId = currentChatId ?: return
|
||||||
|
val tempId = "temp_gif_${System.currentTimeMillis()}"
|
||||||
|
val userId = getCurrentUserId()
|
||||||
|
|
||||||
|
val tempMessage = Message(
|
||||||
|
id = tempId,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = userId,
|
||||||
|
content = url,
|
||||||
|
createdAt = java.util.Date().toString(),
|
||||||
|
mediaType = chats.domain.model.MediaType.IMAGE, // We map GIF to IMAGE for rendering
|
||||||
|
media = listOf(chats.domain.model.Media(url = url, type = "image", id = "temp_media")),
|
||||||
|
senderName = "Вы",
|
||||||
|
senderAvatar = null,
|
||||||
|
reactions = emptyMap(),
|
||||||
|
isRead = false,
|
||||||
|
sequenceId = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.saveMessage(tempMessage)
|
||||||
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val attachment = chats.data.remote.api.AttachmentRequest(
|
val attachment = chats.data.remote.api.AttachmentRequest(
|
||||||
@@ -263,12 +322,8 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment))
|
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment))
|
||||||
|
|
||||||
// Optimistic update
|
repository.deleteLocalMessage(tempId)
|
||||||
_state.update { s ->
|
repository.saveMessage(sentMessage)
|
||||||
if (s.messages.none { it.id == sentMessage.id }) {
|
|
||||||
s.copy(messages = s.messages + sentMessage)
|
|
||||||
} else s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to recent
|
// Add to recent
|
||||||
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
|
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
|
||||||
@@ -287,6 +342,7 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
_state.update { it.copy(recentGifs = newList) }
|
_state.update { it.copy(recentGifs = newList) }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
repository.deleteLocalMessage(tempId)
|
||||||
_state.update { it.copy(error = e.localizedMessage) }
|
_state.update { it.copy(error = e.localizedMessage) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ fun ChatListScreen(
|
|||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val storyState by storyViewModel.state.collectAsState()
|
val storyState by storyViewModel.state.collectAsState()
|
||||||
|
|
||||||
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadChats()
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|||||||
@@ -83,6 +83,18 @@ class ChatListViewModel @Inject constructor(
|
|||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) }
|
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) }
|
||||||
}
|
}
|
||||||
|
is ChatEvent.MessagesRead -> {
|
||||||
|
if (event.userId == getCurrentUserId()) {
|
||||||
|
_state.update { currentState ->
|
||||||
|
val updatedChats = currentState.chats.map { chat ->
|
||||||
|
if (chat.id == event.chatId) {
|
||||||
|
chat.copy(unreadCount = 0)
|
||||||
|
} else chat
|
||||||
|
}
|
||||||
|
currentState.copy(chats = sortChats(updatedChats))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,12 +104,15 @@ class ChatListViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
|
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
val currentUserId = getCurrentUserId()
|
||||||
|
|
||||||
_state.update { currentState ->
|
_state.update { currentState ->
|
||||||
val updatedChats = currentState.chats.map { chat ->
|
val updatedChats = currentState.chats.map { chat ->
|
||||||
if (chat.id == event.message.chatId) {
|
if (chat.id == event.message.chatId) {
|
||||||
|
val isMyMessage = event.message.senderId == currentUserId
|
||||||
chat.copy(
|
chat.copy(
|
||||||
lastMessage = event.message.toDomain(baseUrl),
|
lastMessage = event.message.toDomain(baseUrl),
|
||||||
unreadCount = chat.unreadCount + 1
|
unreadCount = if (isMyMessage) chat.unreadCount else chat.unreadCount + 1
|
||||||
)
|
)
|
||||||
} else chat
|
} else chat
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package core.database.data
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Entity(tableName = "messages")
|
||||||
|
data class MessageEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val chatId: String,
|
||||||
|
val senderId: String,
|
||||||
|
val senderName: String,
|
||||||
|
val senderAvatar: String?,
|
||||||
|
val content: String?,
|
||||||
|
val sequenceId: Int,
|
||||||
|
val createdAt: String,
|
||||||
|
val mediaType: String,
|
||||||
|
val mediaJson: String, // Simplified for now
|
||||||
|
val reactionsJson: String,
|
||||||
|
val isRead: Boolean,
|
||||||
|
val replyToId: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface MessageDao {
|
||||||
|
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC")
|
||||||
|
fun getMessages(chatId: String): Flow<List<MessageEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertMessages(messages: List<MessageEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages WHERE chatId = :chatId")
|
||||||
|
suspend fun clearChat(chatId: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages WHERE id = :messageId")
|
||||||
|
suspend fun deleteMessage(messageId: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Database(entities = [MessageEntity::class], version = 1)
|
||||||
|
abstract class ChatDatabase : RoomDatabase() {
|
||||||
|
abstract fun messageDao(): MessageDao
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package core.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import core.database.data.ChatDatabase
|
||||||
|
import core.database.data.MessageDao
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object DatabaseModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDatabase(@ApplicationContext context: Context): ChatDatabase {
|
||||||
|
return Room.databaseBuilder(
|
||||||
|
context,
|
||||||
|
ChatDatabase::class.java,
|
||||||
|
"chat_database"
|
||||||
|
).fallbackToDestructiveMigration().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideMessageDao(database: ChatDatabase): MessageDao {
|
||||||
|
return database.messageDao()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,12 +61,23 @@ class ForkFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
notificationManager.createNotificationChannel(channel)
|
notificationManager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create intent to open MainActivity
|
||||||
|
val intent = Intent(this, com.knot.messenger.MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
putExtra("type", type)
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, 0, intent,
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||||
|
)
|
||||||
|
|
||||||
val notificationBuilder = NotificationCompat.Builder(this, channelId)
|
val notificationBuilder = NotificationCompat.Builder(this, channelId)
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info) // Заменить на наш Indigo-логотип
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
|
||||||
notificationManager.notify(System.currentTimeMillis().toInt(), notificationBuilder.build())
|
notificationManager.notify(System.currentTimeMillis().toInt(), notificationBuilder.build())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user