Initial QMAX production app

This commit is contained in:
sevenhill
2026-07-03 22:39:48 +03:00
commit 9fcc659438
119 changed files with 17959 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.gms.google-services")
}
val signingProperties = Properties().apply {
val file = rootProject.file("key.properties")
if (file.exists()) {
file.inputStream().use(::load)
}
}
android {
namespace = "xyz.kusoft.qmax"
compileSdk = 36
defaultConfig {
applicationId = "xyz.kusoft.qmax"
minSdk = 26
targetSdk = 36
versionCode = 47
versionName = "0.1.46"
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
}
buildFeatures {
compose = true
buildConfig = true
}
signingConfigs {
create("release") {
val storeFilePath = signingProperties.getProperty("storeFile")
if (!storeFilePath.isNullOrBlank()) {
storeFile = rootProject.file(storeFilePath)
storePassword = signingProperties.getProperty("storePassword")
keyAlias = signingProperties.getProperty("keyAlias")
keyPassword = signingProperties.getProperty("keyPassword")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
signingConfig = if (signingProperties.getProperty("storeFile").isNullOrBlank()) {
signingConfigs.getByName("debug")
} else {
signingConfigs.getByName("release")
}
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.18.0")
implementation("androidx.activity:activity-compose:1.11.0")
implementation("androidx.compose.ui:ui:1.10.5")
implementation("androidx.compose.ui:ui-tooling-preview:1.10.5")
implementation("androidx.compose.material3:material3:1.4.0")
implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.4")
implementation("androidx.navigation:navigation-compose:2.9.1")
implementation("androidx.media3:media3-exoplayer:1.8.0")
implementation("androidx.media3:media3-ui:1.8.0")
implementation("androidx.datastore:datastore-preferences:1.1.7")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("com.google.code.gson:gson:2.11.0")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("com.microsoft.signalr:signalr:8.0.17")
implementation("com.google.firebase:firebase-messaging:25.0.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
debugImplementation("androidx.compose.ui:ui-tooling:1.10.5")
}
+32
View File
@@ -0,0 +1,32 @@
{
"project_info": {
"project_number": "471457572036",
"project_id": "qmax-29df6",
"storage_bucket": "qmax-29df6.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:471457572036:android:249a779d7a39b9c9359591",
"android_client_info": {
"package_name": "xyz.kusoft.qmax"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBYwLKxYG6nxBG6P11XHE-A4r6Pzz2umUk"
},
{
"current_key": "AIzaSyB5QNIY1gBFdz92D_HFxX3wWMCn0HJSUc8"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+54
View File
@@ -0,0 +1,54 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".QMaxApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="QMAX"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".core.push.QMaxFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<receiver
android:name=".core.push.MarkChatReadReceiver"
android:exported="false" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="QMAX_OPEN_CHAT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
package xyz.kusoft.qmax
import android.app.Application
import xyz.kusoft.qmax.core.QMaxContainer
import xyz.kusoft.qmax.core.push.FirebaseBootstrap
import xyz.kusoft.qmax.core.push.ForegroundChatTracker
import xyz.kusoft.qmax.core.push.PushNotifications
class QMaxApplication : Application() {
lateinit var container: QMaxContainer
private set
override fun onCreate() {
super.onCreate()
ForegroundChatTracker.clearActiveChat(this)
FirebaseBootstrap.ensureInitialized(this)
PushNotifications.ensureChannels(this)
container = QMaxContainer(this)
}
}
@@ -0,0 +1,17 @@
package xyz.kusoft.qmax.core
import android.content.Context
import xyz.kusoft.qmax.core.local.AttachmentDiskCache
import xyz.kusoft.qmax.core.local.LocalMessageCache
import xyz.kusoft.qmax.core.network.QMaxApi
import xyz.kusoft.qmax.core.settings.MessageDraftStore
import xyz.kusoft.qmax.core.settings.TokenStore
class QMaxContainer(context: Context) {
val tokenStore = TokenStore(context)
val draftStore = MessageDraftStore(context)
val messageCache = LocalMessageCache(context)
val attachmentCache = AttachmentDiskCache(context)
val api = QMaxApi()
val repository = QMaxRepository(context, api, tokenStore, draftStore, messageCache, attachmentCache)
}
@@ -0,0 +1,501 @@
package xyz.kusoft.qmax.core
import android.content.Context
import android.content.Intent
import android.content.ClipData
import android.content.ActivityNotFoundException
import android.net.Uri
import android.provider.OpenableColumns
import androidx.core.content.FileProvider
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import okio.BufferedSink
import xyz.kusoft.qmax.BuildConfig
import xyz.kusoft.qmax.core.local.AttachmentDiskCache
import xyz.kusoft.qmax.core.local.LocalMessageCache
import xyz.kusoft.qmax.core.model.ArgusManifestDto
import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.AuthResponse
import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.core.network.QMaxApi
import xyz.kusoft.qmax.core.network.QMaxHttpException
import xyz.kusoft.qmax.core.push.FirebaseBootstrap
import xyz.kusoft.qmax.core.settings.MessageDraftStore
import xyz.kusoft.qmax.core.settings.TokenStore
import java.io.File
import java.security.MessageDigest
import java.util.Locale
class QMaxRepository(
private val context: Context,
private val api: QMaxApi,
private val tokenStore: TokenStore,
private val draftStore: MessageDraftStore,
private val messageCache: LocalMessageCache,
private val attachmentCache: AttachmentDiskCache
) {
val session: Flow<QMaxSession?> = tokenStore.session
val drafts: Flow<Map<String, String>> = draftStore.drafts
private val refreshMutex = Mutex()
private var lastRefreshedSession: QMaxSession? = null
suspend fun login(serverUrl: String, pairingCode: String): AuthResponse {
val response = api.login(serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL }, pairingCode, android.os.Build.MODEL)
tokenStore.save(
QMaxSession(
serverUrl = serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL },
accessToken = response.accessToken,
refreshToken = response.refreshToken,
userName = response.user.displayName
)
)
return response
}
suspend fun logout() {
tokenStore.clear()
draftStore.clearAll()
messageCache.clearAll()
attachmentCache.clearAll()
}
suspend fun cachedChats(session: QMaxSession): List<ChatDto> {
return messageCache.chats(session)
}
suspend fun cacheChats(session: QMaxSession, chats: List<ChatDto>) {
messageCache.saveChats(session, orderedChats(chats))
}
suspend fun chats(session: QMaxSession): List<ChatDto> {
val chats = withFreshSession(session) { api.chats(it.serverUrl, it.accessToken) }
if (chats.isEmpty()) {
error("Chat list response is empty.")
}
messageCache.saveChats(session, chats)
return chats
}
suspend fun createChat(session: QMaxSession, externalId: String, title: String): ChatDto {
val chat = withFreshSession(session) { api.createDirect(it.serverUrl, it.accessToken, externalId, title) }
val updatedChats = (cachedChats(session).filterNot { it.id == chat.id } + chat)
.let(::orderedChats)
messageCache.saveChats(session, updatedChats)
return chat
}
suspend fun cachedMessages(session: QMaxSession, chatId: String): List<MessageDto> {
return messageCache.messages(session, chatId)
}
suspend fun messages(session: QMaxSession, chatId: String): List<MessageDto> {
val messages = withFreshSession(session) { api.messages(it.serverUrl, it.accessToken, chatId) }
messageCache.saveMessages(session, chatId, messages)
return messages
}
suspend fun markChatRead(session: QMaxSession, chatId: String) {
withFreshSession(session) { api.markChatRead(it.serverUrl, it.accessToken, chatId) }
val updatedChats = cachedChats(session).map { chat ->
if (chat.id == chatId) chat.copy(unreadCount = 0) else chat
}
messageCache.saveChats(session, orderedChats(updatedChats))
}
suspend fun searchMessages(session: QMaxSession, chatId: String, query: String): List<MessageDto> {
val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) }
messages.forEach { messageCache.upsertMessage(session, it) }
return messages
}
suspend fun chatPresence(session: QMaxSession, chatId: String): ChatPresenceDto {
return withFreshSession(session) { api.chatPresence(it.serverUrl, it.accessToken, chatId) }
}
suspend fun sendMessage(session: QMaxSession, chatId: String, text: String, replyToMessageId: String?): MessageDto {
val message = withFreshSession(session) {
api.sendMessage(it.serverUrl, it.accessToken, chatId, text, replyToMessageId)
}
messageCache.upsertMessage(session, message)
return message
}
suspend fun editMessage(session: QMaxSession, chatId: String, messageId: String, text: String): MessageDto {
val message = withFreshSession(session) {
api.editMessage(it.serverUrl, it.accessToken, chatId, messageId, text)
}
messageCache.upsertMessage(session, message)
return message
}
suspend fun deleteMessage(session: QMaxSession, chatId: String, messageId: String) {
withFreshSession(session) {
api.deleteMessage(it.serverUrl, it.accessToken, chatId, messageId)
}
messageCache.deleteMessage(session, chatId, messageId)
}
suspend fun deleteCachedMessage(session: QMaxSession, chatId: String, messageId: String) {
messageCache.deleteMessage(session, chatId, messageId)
}
suspend fun setReaction(session: QMaxSession, chatId: String, messageId: String, emoji: String): MessageDto {
val message = withFreshSession(session) {
api.setReaction(it.serverUrl, it.accessToken, chatId, messageId, emoji)
}
messageCache.upsertMessage(session, message)
return message
}
suspend fun clearReaction(session: QMaxSession, chatId: String, messageId: String): MessageDto {
val message = withFreshSession(session) {
api.clearReaction(it.serverUrl, it.accessToken, chatId, messageId)
}
messageCache.upsertMessage(session, message)
return message
}
suspend fun forwardMessage(
session: QMaxSession,
sourceChatId: String,
messageId: String,
targetChatId: String
): MessageDto {
val message = withFreshSession(session) {
api.forwardMessage(it.serverUrl, it.accessToken, sourceChatId, messageId, targetChatId)
}
messageCache.upsertMessage(session, message)
return message
}
suspend fun upload(session: QMaxSession, chatId: String, uri: Uri, caption: String?, replyToMessageId: String?): MessageDto {
val meta = queryMeta(uri)
val body = contentUriRequestBody(uri, meta.contentType)
return withFreshSession(session) {
api.uploadAttachment(
it.serverUrl,
it.accessToken,
chatId,
meta.fileName,
meta.contentType,
body,
caption,
replyToMessageId
)
}.also { messageCache.upsertMessage(session, it) }
}
suspend fun uploadVoice(session: QMaxSession, chatId: String, file: File, replyToMessageId: String?): MessageDto {
val contentType = "audio/mp4"
val body = file.asRequestBody(contentType.toMediaTypeOrNull())
return withFreshSession(session) {
api.uploadAttachment(it.serverUrl, it.accessToken, chatId, file.name, contentType, body, null, replyToMessageId)
}.also { messageCache.upsertMessage(session, it) }
}
suspend fun cacheRealtimeMessage(session: QMaxSession, message: MessageDto) {
messageCache.upsertMessage(session, message)
}
suspend fun cachedAttachmentFile(session: QMaxSession, attachment: AttachmentDto): File {
return withFreshSession(session) { freshSession ->
val url = absoluteUrl(freshSession, attachment.downloadPath)
attachmentCache.cachedFile(freshSession, attachment) { target ->
api.downloadToFile(
url = url,
target = target,
token = freshSession.accessToken,
expectedSize = attachment.fileSizeBytes
)
}
}
}
suspend fun openAttachment(session: QMaxSession, attachment: AttachmentDto): File {
val file = cachedAttachmentFile(session, attachment)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val contentType = attachment.contentType.trim()
val fileName = attachment.fileName.lowercase(Locale.ROOT)
val mimeType = when {
contentType.contains("vcard", ignoreCase = true) || fileName.endsWith(".vcf") -> "text/x-vcard"
contentType.isNotBlank() -> contentType
else -> "application/octet-stream"
}
val intent = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, mimeType)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.clipData = ClipData.newUri(context.contentResolver, attachment.fileName, uri)
try {
context.startActivity(Intent.createChooser(intent, attachment.fileName).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} catch (_: ActivityNotFoundException) {
throw IllegalStateException("Нет приложения для открытия этого файла")
}
return file
}
suspend fun shareMessage(session: QMaxSession, message: MessageDto) {
sharePayload(
session = session,
text = message.text?.takeIf { it.isNotBlank() },
attachments = message.attachments.sortedBy { it.sortOrder }
)
}
suspend fun shareAttachment(session: QMaxSession, attachment: AttachmentDto) {
sharePayload(session = session, text = null, attachments = listOf(attachment))
}
suspend fun maxStatus(session: QMaxSession): MaxBridgeStatusDto {
return withFreshSession(session) { api.maxStatus(it.serverUrl, it.accessToken) }
}
suspend fun startMaxLogin(session: QMaxSession, phone: String?): MaxBridgeStatusDto {
return withFreshSession(session) { api.startMaxLogin(it.serverUrl, it.accessToken, phone) }
}
suspend fun submitMaxCode(session: QMaxSession, code: String): MaxBridgeStatusDto {
return withFreshSession(session) { api.submitMaxCode(it.serverUrl, it.accessToken, code) }
}
suspend fun registerCurrentPushDevice(session: QMaxSession) {
if (!FirebaseBootstrap.ensureInitialized(context)) {
return
}
val token = FirebaseMessaging.getInstance().token.await()
registerPushDevice(session, token)
}
suspend fun registerPushDevice(session: QMaxSession, firebaseToken: String) {
if (firebaseToken.isBlank()) {
return
}
withFreshSession(session) {
api.registerPushDevice(it.serverUrl, it.accessToken, firebaseToken)
}
}
private suspend fun sharePayload(session: QMaxSession, text: String?, attachments: List<AttachmentDto>) {
val files = attachments.map { attachment ->
SharedAttachment(
attachment = attachment,
file = cachedAttachmentFile(session, attachment),
mimeType = attachment.contentType.takeIf { it.isNotBlank() } ?: "application/octet-stream"
)
}
val uris = files.map {
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", it.file)
}
val intent = when {
files.isEmpty() -> Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_TEXT, text.orEmpty())
files.size == 1 -> Intent(Intent.ACTION_SEND)
.setType(files.single().mimeType)
.putExtra(Intent.EXTRA_STREAM, uris.single())
else -> Intent(Intent.ACTION_SEND_MULTIPLE)
.setType(commonShareMime(files.map { it.mimeType }))
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
}
text?.takeIf { it.isNotBlank() }?.let {
intent.putExtra(Intent.EXTRA_TEXT, it)
}
if (uris.isNotEmpty()) {
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.clipData = shareClipData(files, uris)
}
try {
context.startActivity(
Intent.createChooser(intent, "Поделиться")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
)
} catch (_: ActivityNotFoundException) {
throw IllegalStateException("Нет приложения, чтобы поделиться этим содержимым")
}
}
private fun shareClipData(files: List<SharedAttachment>, uris: List<Uri>): ClipData {
val first = files.first()
return ClipData.newUri(context.contentResolver, first.attachment.fileName, uris.first()).apply {
uris.drop(1).forEach { uri ->
addItem(ClipData.Item(uri))
}
}
}
private fun commonShareMime(types: List<String>): String {
val normalized = types.map { it.takeIf(String::isNotBlank) ?: "application/octet-stream" }
if (normalized.isEmpty()) return "*/*"
if (normalized.distinct().size == 1) return normalized.first()
val families = normalized.map { it.substringBefore('/', missingDelimiterValue = "*") }.distinct()
return if (families.size == 1 && families.first() != "*") {
"${families.first()}/*"
} else {
"*/*"
}
}
suspend fun refreshSession(session: QMaxSession): QMaxSession {
val response = api.refresh(session.serverUrl, session.refreshToken)
val refreshed = session.copy(
accessToken = response.accessToken,
refreshToken = response.refreshToken,
userName = response.user.displayName
)
tokenStore.save(refreshed)
lastRefreshedSession = refreshed
return refreshed
}
fun absoluteUrl(session: QMaxSession, path: String): String = api.absoluteUrl(session.serverUrl, path)
private fun orderedChats(chats: List<ChatDto>): List<ChatDto> {
return chats.sortedWith(
compareByDescending<ChatDto> { it.isPinned }
.thenByDescending { it.unreadCount > 0 }
.thenByDescending { it.lastMessageAt.orEmpty() }
.thenBy { it.title.lowercase() }
)
}
suspend fun draft(chatId: String): String = draftStore.draft(chatId)
suspend fun saveDraft(chatId: String, text: String) {
draftStore.save(chatId, text)
}
suspend fun clearDraft(chatId: String) {
draftStore.clear(chatId)
}
suspend fun checkArgusUpdate(): ArgusManifestDto? {
val manifest = api.argusManifest() ?: return null
return if (isNewerVersion(manifest.release.version, BuildConfig.VERSION_NAME)) manifest else null
}
suspend fun downloadAndOpenArgusUpdate(manifest: ArgusManifestDto): File {
val downloadUrl = "https://argus.kusoft.xyz${manifest.release.downloadPath}"
val target = File(context.cacheDir, "qmax-${manifest.release.version}.apk")
val size = api.downloadToFile(downloadUrl, target)
if (size != manifest.release.packageSizeBytes) {
target.delete()
error("APK size mismatch")
}
val sha256 = sha256(target)
if (!sha256.equals(manifest.release.sha256, ignoreCase = true)) {
target.delete()
error("APK SHA-256 mismatch")
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", target)
@Suppress("DEPRECATION")
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
.setDataAndType(uri, "application/vnd.android.package-archive")
.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
.putExtra(Intent.EXTRA_RETURN_RESULT, true)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
return target
}
private fun queryMeta(uri: Uri): FileMeta {
val contentType = context.contentResolver.getType(uri) ?: "application/octet-stream"
var fileName = "upload"
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (cursor.moveToFirst() && nameIndex >= 0) {
fileName = cursor.getString(nameIndex) ?: fileName
}
}
return FileMeta(fileName, contentType)
}
private fun contentUriRequestBody(uri: Uri, contentType: String): RequestBody {
return object : RequestBody() {
override fun contentType() = contentType.toMediaTypeOrNull()
override fun writeTo(sink: BufferedSink) {
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Cannot open selected file" }
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read == -1) break
sink.write(buffer, 0, read)
}
}
}
}
}
private data class FileMeta(val fileName: String, val contentType: String)
private suspend fun <T> withFreshSession(
session: QMaxSession,
block: suspend (QMaxSession) -> T
): T {
return try {
block(session)
} catch (error: QMaxHttpException) {
if (error.code != 401) {
throw error
}
val refreshed = refreshMutex.withLock {
lastRefreshedSession
?.takeIf { it.serverUrl == session.serverUrl && it.accessToken != session.accessToken }
?: refreshSession(session)
}
block(refreshed)
}
}
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read == -1) break
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun isNewerVersion(remote: String, local: String): Boolean {
val remoteParts = remote.split('.', '-', '_').map { it.toIntOrNull() ?: 0 }
val localParts = local.split('.', '-', '_').map { it.toIntOrNull() ?: 0 }
val max = maxOf(remoteParts.size, localParts.size)
for (index in 0 until max) {
val r = remoteParts.getOrElse(index) { 0 }
val l = localParts.getOrElse(index) { 0 }
if (r != l) return r > l
}
return false
}
}
private data class SharedAttachment(
val attachment: AttachmentDto,
val file: File,
val mimeType: String
)
@@ -0,0 +1,114 @@
package xyz.kusoft.qmax.core.local
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.QMaxSession
import java.io.File
import java.security.MessageDigest
import java.util.Locale
class AttachmentDiskCache(context: Context) {
private val root = File(context.filesDir, "qmax-attachments")
suspend fun cachedFile(
session: QMaxSession,
attachment: AttachmentDto,
downloader: suspend (File) -> Long
): File {
return withContext(Dispatchers.IO) {
val target = targetFile(session, attachment)
if (isValid(target, attachment)) {
return@withContext target
}
target.delete()
downloader(target)
if (!isValid(target, attachment)) {
val expected = attachment.fileSizeBytes.takeIf { it > 0 }?.toString() ?: "non-empty"
val actual = if (target.exists()) target.length().toString() else "missing"
target.delete()
error("Attachment cache mismatch: expected $expected, got $actual")
}
target
}
}
suspend fun clearAll() {
withContext(Dispatchers.IO) {
root.deleteRecursively()
}
}
private fun targetFile(session: QMaxSession, attachment: AttachmentDto): File {
val sessionDir = root.resolve(cacheKey(session)).also { it.mkdirs() }
val key = sha256(
listOf(
attachment.id,
attachment.downloadPath,
attachment.fileName,
attachment.contentType,
attachment.fileSizeBytes.toString()
).joinToString("|")
).take(40)
return sessionDir.resolve("$key.${extension(attachment)}")
}
private fun isValid(file: File, attachment: AttachmentDto): Boolean {
if (!file.exists() || file.length() <= 0L) {
return false
}
return attachment.fileSizeBytes <= 0L || file.length() == attachment.fileSizeBytes
}
private fun cacheKey(session: QMaxSession): String {
return sha256("${session.serverUrl}|${session.userName}").take(24)
}
private fun extension(attachment: AttachmentDto): String {
val fileExtension = attachment.fileName
.substringAfterLast('.', "")
.lowercase(Locale.ROOT)
.takeIf { it.length in 1..12 && it.all { char -> char.isLetterOrDigit() } }
if (fileExtension != null) {
return fileExtension
}
return when (attachment.contentType.substringBefore(';').lowercase(Locale.ROOT)) {
"image/jpeg" -> "jpg"
"image/png" -> "png"
"image/webp" -> "webp"
"image/gif" -> "gif"
"image/bmp" -> "bmp"
"image/heic" -> "heic"
"image/heif" -> "heif"
"video/mp4" -> "mp4"
"video/quicktime" -> "mov"
"video/webm" -> "webm"
"video/x-matroska" -> "mkv"
"audio/ogg" -> "ogg"
"audio/opus" -> "opus"
"audio/mp4" -> "m4a"
"audio/aac" -> "aac"
"audio/mpeg" -> "mp3"
"audio/wav" -> "wav"
"audio/flac" -> "flac"
"application/pdf" -> "pdf"
"application/zip" -> "zip"
"text/vcard" -> "vcf"
"text/x-vcard" -> "vcf"
"text/plain" -> "txt"
else -> "bin"
}
}
private fun sha256(value: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,176 @@
package xyz.kusoft.qmax.core.local
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession
import java.io.File
import java.security.MessageDigest
class LocalMessageCache(context: Context) {
private val root = File(context.filesDir, "qmax-cache")
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
suspend fun chats(session: QMaxSession): List<ChatDto> {
return withContext(Dispatchers.IO) {
readPayload<ChatCachePayload>(cacheDir(session).resolve("chats.json"))
?.chats
.orEmpty()
.map(::cleanChat)
}
}
suspend fun saveChats(session: QMaxSession, chats: List<ChatDto>) {
withContext(Dispatchers.IO) {
writePayload(cacheDir(session).resolve("chats.json"), ChatCachePayload(chats = chats.map(::cleanChat)))
}
}
suspend fun messages(session: QMaxSession, chatId: String): List<MessageDto> {
return withContext(Dispatchers.IO) {
readPayload<MessageCachePayload>(messageFile(session, chatId))
?.messages
.orEmpty()
.filterNot(::isGenericMediaPlaceholder)
}
}
suspend fun saveMessages(session: QMaxSession, chatId: String, messages: List<MessageDto>) {
withContext(Dispatchers.IO) {
writePayload(
messageFile(session, chatId),
MessageCachePayload(
messages = messages
.filterNot(::isGenericMediaPlaceholder)
.sortedBy { it.sentAt }
.takeLast(MaxMessagesPerChat)
)
)
}
}
suspend fun upsertMessage(session: QMaxSession, message: MessageDto) {
val current = messages(session, message.chatId)
val updated = (current.filterNot { it.id == message.id } + message)
.filterNot(::isGenericMediaPlaceholder)
.sortedBy { it.sentAt }
.takeLast(MaxMessagesPerChat)
saveMessages(session, message.chatId, updated)
}
suspend fun deleteMessage(session: QMaxSession, chatId: String, messageId: String) {
val updated = messages(session, chatId).filterNot { it.id == messageId }
saveMessages(session, chatId, updated)
}
suspend fun clearAll() {
withContext(Dispatchers.IO) {
root.deleteRecursively()
}
}
private fun cacheDir(session: QMaxSession): File {
return root.resolve(cacheKey(session)).also { it.mkdirs() }
}
private fun messageFile(session: QMaxSession, chatId: String): File {
return cacheDir(session).resolve("messages-${sha256(chatId).take(24)}.json")
}
private inline fun <reified T> readPayload(file: File): T? {
if (!file.exists() || file.length() == 0L) {
return null
}
return runCatching {
json.decodeFromString<T>(file.readText())
}.getOrNull()
}
private inline fun <reified T> writePayload(file: File, payload: T) {
file.parentFile?.mkdirs()
val part = File(file.parentFile, "${file.name}.part")
part.writeText(json.encodeToString(payload))
if (file.exists()) {
file.delete()
}
part.renameTo(file)
}
private fun cacheKey(session: QMaxSession): String {
return sha256("${session.serverUrl}|${session.userName}").take(24)
}
private fun sha256(value: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
private fun cleanChat(chat: ChatDto): ChatDto {
return when {
isGenericMediaLabel(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u041c\u0435\u0434\u0438\u0430")
isQuestionMarkArtifact(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435")
else -> chat
}
}
private fun isGenericMediaPlaceholder(message: MessageDto): Boolean {
return message.attachments.isEmpty() && isGenericMediaLabel(message.text)
}
private fun isQuestionMarkArtifact(value: String?): Boolean {
val text = value?.trim()
if (text.isNullOrBlank()) return false
val questionMarks = text.count { it == '?' || it == '\uFFFD' }
if (questionMarks < 4) return false
val meaningfulCharacters = text.count { it.isLetterOrDigit() && it != '?' }
return meaningfulCharacters == 0
}
private fun isGenericMediaLabel(value: String?): Boolean {
return when (value?.trim()?.lowercase()) {
"\u0444\u043e\u0442\u043e",
"photo",
"image",
"gif",
"\u0432\u0438\u0434\u0435\u043e",
"video",
"\u0430\u0443\u0434\u0438\u043e",
"audio",
"\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435",
"voice",
"\u0441\u0442\u0438\u043a\u0435\u0440",
"sticker",
"\u044d\u043c\u043e\u0434\u0437\u0438",
"emoji",
"\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u0435\u043d\u043d\u044b\u0435 \u0444\u043e\u0442\u043e",
"\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u0451\u043d\u043d\u044b\u0435 \u0444\u043e\u0442\u043e",
"attached photos" -> true
else -> false
}
}
@Serializable
private data class ChatCachePayload(
val version: Int = 1,
val chats: List<ChatDto>
)
@Serializable
private data class MessageCachePayload(
val version: Int = 1,
val messages: List<MessageDto>
)
private companion object {
const val MaxMessagesPerChat = 250
}
}
@@ -0,0 +1,184 @@
package xyz.kusoft.qmax.core.model
import kotlinx.serialization.Serializable
@Serializable
data class UserDto(
val id: String,
val displayName: String,
val phoneNumber: String? = null,
val avatarPath: String? = null
)
@Serializable
data class AuthResponse(
val accessToken: String,
val refreshToken: String,
val expiresAt: String,
val user: UserDto
)
@Serializable
data class DeviceLoginRequest(
val pairingCode: String,
val deviceName: String
)
@Serializable
data class RefreshTokenRequest(val refreshToken: String)
@Serializable
data class ChatDto(
val id: String,
val externalId: String? = null,
val kind: String,
val title: String,
val avatarUrl: String? = null,
val lastMessagePreview: String? = null,
val lastMessageAt: String? = null,
val unreadCount: Int = 0,
val isPinned: Boolean = false,
val isMuted: Boolean = false,
val isArchived: Boolean = false
)
@Serializable
data class ChatPresenceDto(
val isTyping: Boolean = false,
val statusText: String? = null,
val updatedAt: String? = null
)
@Serializable
data class MessageDto(
val id: String,
val chatId: String,
val externalId: String? = null,
val senderName: String? = null,
val direction: String,
val text: String? = null,
val sentAt: String,
val editedAt: String? = null,
val deletedAt: String? = null,
val replyToMessageId: String? = null,
val forwardedFrom: String? = null,
val deliveryState: String,
val error: String? = null,
val reactions: List<ReactionDto> = emptyList(),
val attachments: List<AttachmentDto> = emptyList()
)
@Serializable
data class ReactionDto(
val emoji: String,
val count: Int,
val reactedByMe: Boolean = false
)
@Serializable
data class AttachmentDto(
val id: String,
val fileName: String,
val contentType: String,
val fileSizeBytes: Long,
val downloadPath: String,
val kind: String,
val sortOrder: Int
)
@Serializable
data class SendMessageRequest(
val text: String,
val replyToMessageId: String? = null
)
@Serializable
data class MarkChatReadRequest(
val read: Boolean = true
)
@Serializable
data class EditMessageRequest(
val text: String
)
@Serializable
data class ForwardMessageRequest(
val targetChatId: String
)
@Serializable
data class SetReactionRequest(
val emoji: String
)
@Serializable
data class MessageDeletedDto(
val chatId: String,
val messageId: String
)
@Serializable
data class CreateDirectChatRequest(
val externalChatId: String,
val title: String,
val avatarUrl: String? = null
)
@Serializable
data class MaxBridgeStatusDto(
val mode: String,
val isAuthorized: Boolean,
val loginStage: String? = null,
val status: String,
val url: String? = null,
val title: String? = null,
val lastError: String? = null,
val updatedAt: String
)
@Serializable
data class BeginMaxLoginRequest(val phoneNumber: String? = null)
@Serializable
data class SubmitMaxCodeRequest(val code: String)
@Serializable
data class RegisterPushDeviceRequest(
val firebaseToken: String,
val platform: String = "android"
)
data class QMaxSession(
val serverUrl: String,
val accessToken: String,
val refreshToken: String,
val userName: String
)
@Serializable
data class ArgusManifestDto(
val slug: String,
val name: String,
val summary: String? = null,
val description: String? = null,
val repositoryUrl: String? = null,
val homepageUrl: String? = null,
val release: ArgusReleaseDto
)
@Serializable
data class ArgusReleaseDto(
val id: String,
val version: String,
val channel: String,
val platform: String,
val packageKind: String,
val downloadPath: String,
val originalFileName: String,
val contentType: String,
val packageSizeBytes: Long,
val sha256: String,
val publishedAt: String,
val notes: String? = null
)
@@ -0,0 +1,418 @@
package xyz.kusoft.qmax.core.network
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import xyz.kusoft.qmax.BuildConfig
import xyz.kusoft.qmax.core.model.AuthResponse
import xyz.kusoft.qmax.core.model.ArgusManifestDto
import xyz.kusoft.qmax.core.model.BeginMaxLoginRequest
import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto
import xyz.kusoft.qmax.core.model.CreateDirectChatRequest
import xyz.kusoft.qmax.core.model.DeviceLoginRequest
import xyz.kusoft.qmax.core.model.EditMessageRequest
import xyz.kusoft.qmax.core.model.ForwardMessageRequest
import xyz.kusoft.qmax.core.model.MarkChatReadRequest
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.RegisterPushDeviceRequest
import xyz.kusoft.qmax.core.model.SendMessageRequest
import xyz.kusoft.qmax.core.model.SetReactionRequest
import xyz.kusoft.qmax.core.model.SubmitMaxCodeRequest
import java.io.IOException
import java.io.File
import java.io.FileOutputStream
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class QMaxHttpException(val code: Int, message: String) : IOException(message)
class QMaxApi {
private val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(150, TimeUnit.SECONDS)
.build()
private val downloadClient = client.newBuilder()
.callTimeout(5, TimeUnit.MINUTES)
.build()
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
suspend fun login(serverUrl: String, pairingCode: String, deviceName: String): AuthResponse {
return post(serverUrl, "/api/auth/device/login", null, DeviceLoginRequest(pairingCode, deviceName))
}
suspend fun refresh(serverUrl: String, refreshToken: String): AuthResponse {
return post(serverUrl, "/api/auth/refresh", null, xyz.kusoft.qmax.core.model.RefreshTokenRequest(refreshToken))
}
suspend fun chats(sessionServerUrl: String, token: String): List<ChatDto> {
return get(sessionServerUrl, "/api/chats", token)
}
suspend fun createDirect(sessionServerUrl: String, token: String, externalChatId: String, title: String): ChatDto {
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title))
}
suspend fun messages(sessionServerUrl: String, token: String, chatId: String): List<MessageDto> {
return get(sessionServerUrl, "/api/chats/$chatId/messages", token)
}
suspend fun markChatRead(sessionServerUrl: String, token: String, chatId: String) {
postNoContent(sessionServerUrl, "/api/chats/$chatId/read", token, MarkChatReadRequest())
}
suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List<MessageDto> {
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token)
}
suspend fun chatPresence(sessionServerUrl: String, token: String, chatId: String): ChatPresenceDto {
return get(sessionServerUrl, "/api/chats/$chatId/presence", token)
}
suspend fun sendMessage(
sessionServerUrl: String,
token: String,
chatId: String,
text: String,
replyToMessageId: String?
): MessageDto {
return post(sessionServerUrl, "/api/chats/$chatId/messages", token, SendMessageRequest(text, replyToMessageId))
}
suspend fun editMessage(
sessionServerUrl: String,
token: String,
chatId: String,
messageId: String,
text: String
): MessageDto {
return patch(sessionServerUrl, "/api/chats/$chatId/messages/$messageId", token, EditMessageRequest(text))
}
suspend fun deleteMessage(sessionServerUrl: String, token: String, chatId: String, messageId: String) {
delete(sessionServerUrl, "/api/chats/$chatId/messages/$messageId", token)
}
suspend fun setReaction(sessionServerUrl: String, token: String, chatId: String, messageId: String, emoji: String): MessageDto {
return put(sessionServerUrl, "/api/chats/$chatId/messages/$messageId/reaction", token, SetReactionRequest(emoji))
}
suspend fun clearReaction(sessionServerUrl: String, token: String, chatId: String, messageId: String): MessageDto {
return deleteForJson(sessionServerUrl, "/api/chats/$chatId/messages/$messageId/reaction", token)
}
suspend fun forwardMessage(
sessionServerUrl: String,
token: String,
sourceChatId: String,
messageId: String,
targetChatId: String
): MessageDto {
return post(
sessionServerUrl,
"/api/chats/$sourceChatId/messages/$messageId/forward",
token,
ForwardMessageRequest(targetChatId)
)
}
suspend fun uploadAttachment(
sessionServerUrl: String,
token: String,
chatId: String,
fileName: String,
contentType: String,
body: RequestBody,
caption: String?,
replyToMessageId: String?
): MessageDto {
val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", fileName, body)
.apply {
if (!caption.isNullOrBlank()) addFormDataPart("caption", caption)
if (!replyToMessageId.isNullOrBlank()) addFormDataPart("replyToMessageId", replyToMessageId)
}
.build()
val request = Request.Builder()
.url("${sessionServerUrl.trimEnd('/')}/api/chats/$chatId/attachments")
.header("Authorization", "Bearer $token")
.post(multipart)
.build()
return execute(request)
}
suspend fun maxStatus(sessionServerUrl: String, token: String): MaxBridgeStatusDto {
return get(sessionServerUrl, "/api/max/status", token)
}
suspend fun startMaxLogin(sessionServerUrl: String, token: String, phone: String?): MaxBridgeStatusDto {
return post(sessionServerUrl, "/api/max/login/start", token, BeginMaxLoginRequest(phone))
}
suspend fun submitMaxCode(sessionServerUrl: String, token: String, code: String): MaxBridgeStatusDto {
return post(sessionServerUrl, "/api/max/login/code", token, SubmitMaxCodeRequest(code))
}
suspend fun registerPushDevice(sessionServerUrl: String, token: String, firebaseToken: String) {
postNoContent(sessionServerUrl, "/api/push/devices", token, RegisterPushDeviceRequest(firebaseToken))
}
fun absoluteUrl(serverUrl: String, path: String): String {
return if (path.startsWith("http")) path else "${serverUrl.trimEnd('/')}$path"
}
suspend fun argusManifest(
baseUrl: String = "https://argus.kusoft.xyz",
slug: String = "qmax",
platform: String = "android",
channel: String = "stable"
): ArgusManifestDto? {
val request = Request.Builder()
.url("${baseUrl.trimEnd('/')}/api/apps/$slug/manifest?platform=$platform&channel=$channel")
.get()
.build()
return withContext(Dispatchers.IO) {
debugLog("GET ${request.url.encodedPath}")
client.newCall(request).execute().use { response ->
if (response.code == 404) return@withContext null
val body = response.body?.string().orEmpty()
debugLog("GET ${request.url.encodedPath} -> ${response.code}")
if (!response.isSuccessful) throw QMaxHttpException(response.code, httpErrorMessage(response.code, body))
json.decodeFromString<ArgusManifestDto>(body)
}
}
}
suspend fun downloadToFile(url: String, target: File, token: String? = null, expectedSize: Long? = null): Long {
val request = Request.Builder()
.url(url)
.apply {
if (!token.isNullOrBlank()) header("Authorization", "Bearer $token")
}
.get()
.build()
return withContext(Dispatchers.IO) {
debugLog("DOWNLOAD ${request.url.encodedPath}")
downloadClient.newCall(request).execute().use { response ->
debugLog("DOWNLOAD ${request.url.encodedPath} -> ${response.code}")
target.parentFile?.mkdirs()
val part = File(target.parentFile, target.name + ".part")
if (part.exists()) part.delete()
if (!response.isSuccessful) {
val responseBody = response.body?.string().orEmpty()
throw QMaxHttpException(response.code, httpErrorMessage(response.code, responseBody))
}
response.body?.byteStream().use { input ->
requireNotNull(input) { "Empty response body" }
FileOutputStream(part).use { output ->
input.copyTo(output)
}
}
if (expectedSize != null && expectedSize > 0 && part.length() != expectedSize) {
val actual = part.length()
part.delete()
throw IOException("Download size mismatch: expected $expectedSize, got $actual")
}
if (target.exists()) target.delete()
if (!part.renameTo(target)) {
part.delete()
throw IOException("Cannot move downloaded file into cache")
}
target.length()
}
}
}
private suspend inline fun <reified T> get(serverUrl: String, path: String, token: String): T {
val request = Request.Builder()
.url("${serverUrl.trimEnd('/')}$path")
.header("Authorization", "Bearer $token")
.get()
.build()
return execute(request)
}
private suspend inline fun <reified T, reified B> post(serverUrl: String, path: String, token: String?, body: B): T {
val requestBody = json.encodeToString(body).toRequestBody(jsonMediaType)
val request = Request.Builder()
.url("${serverUrl.trimEnd('/')}$path")
.apply {
if (!token.isNullOrBlank()) header("Authorization", "Bearer $token")
}
.post(requestBody)
.build()
return execute(request)
}
private suspend inline fun <reified T, reified B> put(serverUrl: String, path: String, token: String, body: B): T {
val requestBody = json.encodeToString(body).toRequestBody(jsonMediaType)
val request = Request.Builder()
.url("${serverUrl.trimEnd('/')}$path")
.header("Authorization", "Bearer $token")
.put(requestBody)
.build()
return execute(request)
}
private suspend inline fun <reified T, reified B> patch(serverUrl: String, path: String, token: String, body: B): T {
val requestBody = json.encodeToString(body).toRequestBody(jsonMediaType)
val request = Request.Builder()
.url("${serverUrl.trimEnd('/')}$path")
.header("Authorization", "Bearer $token")
.patch(requestBody)
.build()
return execute(request)
}
private suspend fun delete(serverUrl: String, path: String, token: String) {
val request = Request.Builder()
.url("${serverUrl.trimEnd('/')}$path")
.header("Authorization", "Bearer $token")
.delete()
.build()
withContext(Dispatchers.IO) {
debugLog("DELETE ${request.url.encodedPath}")
client.newCall(request).execute().use { response ->
val responseBody = response.body?.string().orEmpty()
debugLog("DELETE ${request.url.encodedPath} -> ${response.code}")
if (!response.isSuccessful) {
throw QMaxHttpException(response.code, httpErrorMessage(response.code, responseBody))
}
}
}
}
private suspend inline fun <reified T> deleteForJson(serverUrl: String, path: String, token: String): T {
val request = Request.Builder()
.url("${serverUrl.trimEnd('/')}$path")
.header("Authorization", "Bearer $token")
.delete()
.build()
return execute(request)
}
private suspend inline fun <reified B> postNoContent(serverUrl: String, path: String, token: String, body: B) {
val requestBody = json.encodeToString(body).toRequestBody(jsonMediaType)
val request = Request.Builder()
.url("${serverUrl.trimEnd('/')}$path")
.header("Authorization", "Bearer $token")
.post(requestBody)
.build()
withContext(Dispatchers.IO) {
debugLog("POST ${request.url.encodedPath}")
client.newCall(request).execute().use { response ->
val responseBody = response.body?.string().orEmpty()
debugLog("POST ${request.url.encodedPath} -> ${response.code}")
if (!response.isSuccessful) {
throw QMaxHttpException(response.code, httpErrorMessage(response.code, responseBody))
}
}
}
}
private suspend inline fun <reified T> execute(request: Request): T {
debugLog("${request.method} ${request.url.encodedPath}")
val result = executeCancellable(request)
debugLog("${request.method} ${request.url.encodedPath} -> ${result.code}")
if (!result.isSuccessful) {
throw QMaxHttpException(result.code, httpErrorMessage(result.code, result.body))
}
return json.decodeFromString(result.body)
}
private suspend fun executeCancellable(request: Request): HttpResult {
return suspendCancellableCoroutine { continuation ->
val call = client.newCall(request)
continuation.invokeOnCancellation { call.cancel() }
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isActive) {
continuation.resumeWithException(e)
}
}
override fun onResponse(call: Call, response: Response) {
response.use {
try {
val body = it.body?.string().orEmpty()
if (continuation.isActive) {
continuation.resume(HttpResult(it.code, it.isSuccessful, body))
}
} catch (error: Throwable) {
if (continuation.isActive) {
continuation.resumeWithException(error)
}
}
}
}
})
}
}
private data class HttpResult(
val code: Int,
val isSuccessful: Boolean,
val body: String
)
private fun httpErrorMessage(code: Int, body: String): String {
val trimmed = body.trim()
if (trimmed.isBlank()) {
return "HTTP $code"
}
runCatching {
val jsonObject = json.parseToJsonElement(trimmed).jsonObject
listOf("detail", "error", "message", "title")
.firstNotNullOfOrNull { key ->
jsonObject[key]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
}
}.getOrNull()?.let { message ->
return message
}
return trimmed.take(500)
}
private fun debugLog(message: String) {
if (BuildConfig.DEBUG) {
Log.d(LogTag, message)
}
}
private companion object {
const val LogTag = "QMAX"
}
}
@@ -0,0 +1,99 @@
package xyz.kusoft.qmax.core.push
import android.content.Context
import android.util.Log
import com.google.firebase.FirebaseApp
import com.google.firebase.FirebaseOptions
import org.json.JSONObject
object FirebaseBootstrap {
private const val tag = "QMaxFirebase"
fun ensureInitialized(context: Context): Boolean {
if (FirebaseApp.getApps(context).isNotEmpty()) {
return true
}
val config = readFirebaseAndroidConfig(context)
?: readGoogleServicesConfig(context)
?: return false
return runCatching {
val options = FirebaseOptions.Builder()
.setApplicationId(config.applicationId)
.setApiKey(config.apiKey)
.setProjectId(config.projectId)
.setGcmSenderId(config.senderId)
.build()
FirebaseApp.initializeApp(context, options)
true
}.onFailure {
Log.w(tag, "Firebase initialization failed", it)
}.getOrDefault(false)
}
private fun readFirebaseAndroidConfig(context: Context): FirebaseConfig? {
val json = assetText(context, "firebase.android.json") ?: return null
val obj = JSONObject(json)
return FirebaseConfig(
applicationId = obj.optString("applicationId"),
apiKey = obj.optString("apiKey"),
projectId = obj.optString("projectId"),
senderId = obj.optString("senderId")
).takeIf { it.isComplete }
}
private fun readGoogleServicesConfig(context: Context): FirebaseConfig? {
val json = assetText(context, "google-services.json") ?: return null
val obj = JSONObject(json)
val project = obj.getJSONObject("project_info")
val clients = obj.optJSONArray("client") ?: return null
var fallback: FirebaseConfig? = null
for (index in 0 until clients.length()) {
val client = clients.getJSONObject(index)
val clientInfo = client.getJSONObject("client_info")
val packageName = clientInfo
.getJSONObject("android_client_info")
.optString("package_name")
val apiKey = client
.getJSONArray("api_key")
.getJSONObject(0)
.optString("current_key")
val config = FirebaseConfig(
applicationId = clientInfo.optString("mobilesdk_app_id"),
apiKey = apiKey,
projectId = project.optString("project_id"),
senderId = project.optString("project_number")
).takeIf { it.isComplete }
if (packageName == context.packageName) {
return config
}
if (fallback == null) {
fallback = config
}
}
return fallback
}
private fun assetText(context: Context, name: String): String? {
return runCatching {
context.assets.open(name).bufferedReader().use { it.readText() }
}.getOrNull()
}
private data class FirebaseConfig(
val applicationId: String,
val apiKey: String,
val projectId: String,
val senderId: String
) {
val isComplete: Boolean
get() = applicationId.isNotBlank() &&
apiKey.isNotBlank() &&
projectId.isNotBlank() &&
senderId.isNotBlank()
}
}
@@ -0,0 +1,36 @@
package xyz.kusoft.qmax.core.push
import android.content.Context
object ForegroundChatTracker {
private const val PreferencesName = "qmax_foreground_chat"
private const val ActiveChatIdKey = "active_chat_id"
fun setActiveChat(context: Context, chatId: String) {
context.applicationContext
.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE)
.edit()
.putString(ActiveChatIdKey, chatId)
.apply()
}
fun clearActiveChat(context: Context, chatId: String? = null) {
val preferences = context.applicationContext.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE)
if (chatId != null && preferences.getString(ActiveChatIdKey, null) != chatId) {
return
}
preferences.edit().remove(ActiveChatIdKey).apply()
}
fun isChatActive(context: Context, chatId: String?): Boolean {
if (chatId.isNullOrBlank()) {
return false
}
return context.applicationContext
.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE)
.getString(ActiveChatIdKey, null)
?.equals(chatId, ignoreCase = true) == true
}
}
@@ -0,0 +1,44 @@
package xyz.kusoft.qmax.core.push
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import xyz.kusoft.qmax.core.QMaxContainer
class MarkChatReadReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != PushNotifications.MarkChatReadAction) {
return
}
val chatId = intent.getStringExtra(PushNotifications.ExtraChatId)
?.takeIf { it.isNotBlank() }
?: return
val notificationId = intent.getIntExtra(PushNotifications.ExtraNotificationId, 0)
if (notificationId != 0) {
NotificationManagerCompat.from(context).cancel(notificationId)
}
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
val container = QMaxContainer(context.applicationContext)
val session = container.tokenStore.session.firstOrNull()
if (session != null) {
container.repository.markChatRead(session, chatId)
}
} catch (error: Throwable) {
Log.w("QMaxPush", "Unable to mark notification chat as read", error)
} finally {
pendingResult.finish()
}
}
}
}
@@ -0,0 +1,34 @@
package xyz.kusoft.qmax.core.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
object PushNotifications {
const val MessagesChannelId = "qmax_messages_v2"
const val OpenChatAction = "QMAX_OPEN_CHAT"
const val MarkChatReadAction = "QMAX_MARK_CHAT_READ"
const val ExtraChatId = "chatId"
const val ExtraMessageId = "messageId"
const val ExtraNotificationId = "notificationId"
fun ensureChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = context.getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
MessagesChannelId,
"QMAX messages",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Incoming QMAX messages"
enableVibration(true)
setShowBadge(true)
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
}
manager.createNotificationChannel(channel)
}
}
@@ -0,0 +1,179 @@
package xyz.kusoft.qmax.core.push
import android.app.PendingIntent
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import xyz.kusoft.qmax.MainActivity
import xyz.kusoft.qmax.QMaxApplication
import xyz.kusoft.qmax.R
class QMaxFirebaseMessagingService : FirebaseMessagingService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onNewToken(token: String) {
super.onNewToken(token)
registerToken(token)
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
PushNotifications.ensureChannels(this)
val type = message.data["type"]
if (type != null && type != "new_message") {
return
}
val title = message.notification?.title
?: message.data["title"]
?: message.data["chatTitle"]
?: "QMAX"
val body = message.notification?.body
?: message.data["body"]
?: "New message"
if (isPresenceText(body)) {
return
}
val chatId = message.data[PushNotifications.ExtraChatId]
val messageId = message.data[PushNotifications.ExtraMessageId]
if (ForegroundChatTracker.isChatActive(this, chatId)) {
return
}
val notificationId = stableNotificationId(chatId ?: messageId ?: body)
val intent = Intent(this, MainActivity::class.java)
.setAction(PushNotifications.OpenChatAction)
.putExtra(PushNotifications.ExtraChatId, chatId)
.putExtra(PushNotifications.ExtraMessageId, messageId)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
val pendingIntent = PendingIntent.getActivity(
this,
notificationId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val markReadPendingIntent = chatId?.takeIf { it.isNotBlank() }?.let { id ->
val markReadIntent = Intent(this, MarkChatReadReceiver::class.java)
.setAction(PushNotifications.MarkChatReadAction)
.putExtra(PushNotifications.ExtraChatId, id)
.putExtra(PushNotifications.ExtraNotificationId, notificationId)
PendingIntent.getBroadcast(
this,
notificationId + 1,
markReadIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
val notification = NotificationCompat.Builder(this, PushNotifications.MessagesChannelId)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.apply {
if (markReadPendingIntent != null) {
addAction(
R.drawable.ic_mark_read_24,
"\u041f\u0440\u043e\u0447\u0438\u0442\u0430\u043d\u043e",
markReadPendingIntent
)
}
}
.build()
runCatching {
NotificationManagerCompat.from(this)
.notify(notificationId, notification)
}.onFailure {
Log.w("QMaxPush", "Unable to show notification", it)
}
}
private fun registerToken(token: String) {
if (token.isBlank()) {
return
}
scope.launch {
val container = (application as QMaxApplication).container
val session = container.tokenStore.session.firstOrNull() ?: return@launch
runCatching {
container.repository.registerPushDevice(session, token)
}.onFailure {
Log.w("QMaxPush", "Unable to register refreshed FCM token", it)
}
}
}
private fun stableNotificationId(value: String): Int {
return value.hashCode() and Int.MAX_VALUE
}
private fun isPresenceText(value: String): Boolean {
val text = value
.trim()
.lowercase()
.replace("...", "")
.replace("\u2026", "")
.trim()
if (text.isBlank() || text.length > 120) {
return false
}
if (text.contains("\u043f\u0435\u0447\u0430\u0442") ||
text.contains("\u043d\u0430\u0431\u0438\u0440\u0430") ||
text.contains("typing")
) {
return true
}
val hasProcessVerb = text.contains("\u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442") ||
text.contains("\u0432\u044b\u0431\u0438\u0440\u0430\u0435\u0442") ||
text.contains("\u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442") ||
text.contains("\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u044f\u0435\u0442") ||
text.contains("\u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0435\u0442") ||
text.contains("is sending") ||
text.contains("sending") ||
text.contains("choosing") ||
text.contains("uploading") ||
text.contains("recording")
if (!hasProcessVerb) {
return false
}
return text.contains("\u0444\u043e\u0442\u043e") ||
text.contains("\u0438\u0437\u043e\u0431\u0440\u0430\u0436") ||
text.contains("\u043a\u0430\u0440\u0442\u0438\u043d") ||
text.contains("\u0432\u0438\u0434\u0435\u043e") ||
text.contains("\u0441\u0442\u0438\u043a\u0435\u0440") ||
text.contains("\u044d\u043c\u043e\u0434\u0437\u0438") ||
text.contains("\u0433\u043e\u043b\u043e\u0441") ||
text.contains("\u0430\u0443\u0434\u0438\u043e") ||
text.contains("\u0444\u0430\u0439\u043b") ||
text.contains("photo") ||
text.contains("image") ||
text.contains("video") ||
text.contains("sticker") ||
text.contains("emoji") ||
text.contains("voice") ||
text.contains("audio") ||
text.contains("file")
}
}
@@ -0,0 +1,101 @@
package xyz.kusoft.qmax.core.realtime
import com.google.gson.JsonElement
import com.microsoft.signalr.HubConnection
import com.microsoft.signalr.HubConnectionBuilder
import com.microsoft.signalr.HubConnectionState
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import xyz.kusoft.qmax.core.model.MessageDeletedDto
import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession
class QMaxRealtimeClient {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private var hub: HubConnection? = null
private var joinedChatId: String? = null
suspend fun connect(
session: QMaxSession,
onChatListInvalidated: () -> Unit,
onMessageCreated: (MessageDto) -> Unit,
onMessageUpdated: (MessageDto) -> Unit,
onMessageDeleted: (MessageDeletedDto) -> Unit
) = withContext(Dispatchers.IO) {
disconnect()
val connection = HubConnectionBuilder
.create("${session.serverUrl.trimEnd('/')}/hubs/qmax")
.withAccessTokenProvider(Single.just(session.accessToken))
.build()
connection.on("ChatListInvalidated", onChatListInvalidated)
connection.on(
"MessageCreated",
{ payload: JsonElement ->
runCatching {
onMessageCreated(json.decodeFromString<MessageDto>(payload.toString()))
}
},
JsonElement::class.java
)
connection.on(
"MessageUpdated",
{ payload: JsonElement ->
runCatching {
onMessageUpdated(json.decodeFromString<MessageDto>(payload.toString()))
}
},
JsonElement::class.java
)
connection.on(
"MessageDeleted",
{ payload: JsonElement ->
runCatching {
onMessageDeleted(json.decodeFromString<MessageDeletedDto>(payload.toString()))
}
},
JsonElement::class.java
)
connection.onClosed {
joinedChatId = null
}
connection.start().blockingAwait()
hub = connection
}
suspend fun joinChat(chatId: String?) = withContext(Dispatchers.IO) {
val connection = hub ?: return@withContext
if (connection.connectionState != HubConnectionState.CONNECTED) {
return@withContext
}
val previous = joinedChatId
if (!previous.isNullOrBlank() && previous != chatId) {
runCatching { connection.send("LeaveChat", previous) }
}
if (!chatId.isNullOrBlank() && previous != chatId) {
connection.send("JoinChat", chatId)
joinedChatId = chatId
} else if (chatId.isNullOrBlank()) {
joinedChatId = null
}
}
suspend fun disconnect() = withContext(Dispatchers.IO) {
val connection = hub
hub = null
joinedChatId = null
if (connection != null && connection.connectionState != HubConnectionState.DISCONNECTED) {
runCatching { connection.stop().blockingAwait() }
}
}
}
@@ -0,0 +1,54 @@
package xyz.kusoft.qmax.core.settings
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val Context.qmaxDraftDataStore by preferencesDataStore("qmax_drafts")
class MessageDraftStore(private val context: Context) {
private val prefix = "draft_"
val drafts: Flow<Map<String, String>> = context.qmaxDraftDataStore.data.map { prefs ->
prefs.asMap().mapNotNull { (key, value) ->
val name = key.name
val text = value as? String
if (!name.startsWith(prefix) || text.isNullOrBlank()) {
null
} else {
name.removePrefix(prefix) to text
}
}.toMap()
}
suspend fun draft(chatId: String): String {
return context.qmaxDraftDataStore.data
.map { it[draftKey(chatId)].orEmpty() }
.first()
}
suspend fun save(chatId: String, text: String) {
context.qmaxDraftDataStore.edit { prefs ->
val key = draftKey(chatId)
if (text.isBlank()) {
prefs.remove(key)
} else {
prefs[key] = text
}
}
}
suspend fun clear(chatId: String) {
context.qmaxDraftDataStore.edit { it.remove(draftKey(chatId)) }
}
suspend fun clearAll() {
context.qmaxDraftDataStore.edit { it.clear() }
}
private fun draftKey(chatId: String) = stringPreferencesKey("$prefix$chatId")
}
@@ -0,0 +1,47 @@
package xyz.kusoft.qmax.core.settings
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import xyz.kusoft.qmax.BuildConfig
import xyz.kusoft.qmax.core.model.QMaxSession
private val Context.qmaxDataStore by preferencesDataStore("qmax")
class TokenStore(private val context: Context) {
private val serverUrlKey = stringPreferencesKey("server_url")
private val accessTokenKey = stringPreferencesKey("access_token")
private val refreshTokenKey = stringPreferencesKey("refresh_token")
private val userNameKey = stringPreferencesKey("user_name")
val session: Flow<QMaxSession?> = context.qmaxDataStore.data.map { prefs ->
val accessToken = prefs[accessTokenKey]
val refreshToken = prefs[refreshTokenKey]
if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) {
null
} else {
QMaxSession(
serverUrl = prefs[serverUrlKey] ?: BuildConfig.QMAX_DEFAULT_SERVER_URL,
accessToken = accessToken,
refreshToken = refreshToken,
userName = prefs[userNameKey] ?: "QMAX"
)
}
}
suspend fun save(session: QMaxSession) {
context.qmaxDataStore.edit { prefs ->
prefs[serverUrlKey] = session.serverUrl
prefs[accessTokenKey] = session.accessToken
prefs[refreshTokenKey] = session.refreshToken
prefs[userNameKey] = session.userName
}
}
suspend fun clear() {
context.qmaxDataStore.edit { it.clear() }
}
}
@@ -0,0 +1,950 @@
package xyz.kusoft.qmax.ui
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import xyz.kusoft.qmax.BuildConfig
import xyz.kusoft.qmax.core.QMaxRepository
import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MessageDeletedDto
import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient
import java.io.File
data class QMaxUiState(
val serverUrl: String = BuildConfig.QMAX_DEFAULT_SERVER_URL,
val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE,
val session: QMaxSession? = null,
val chats: List<ChatDto> = emptyList(),
val selectedChat: ChatDto? = null,
val messages: List<MessageDto> = emptyList(),
val chatSearchResults: List<MessageDto> = emptyList(),
val chatSearchLoading: Boolean = false,
val chatPresenceText: String? = null,
val replyTarget: MessageDto? = null,
val editTarget: MessageDto? = null,
val forwardTarget: MessageDto? = null,
val drafts: Map<String, String> = emptyMap(),
val searchQuery: String = "",
val composerText: String = "",
val newChatExternalId: String = "",
val newChatTitle: String = "",
val maxStatus: MaxBridgeStatusDto? = null,
val maxCode: String = "",
val updateMessage: String? = null,
val loading: Boolean = false,
val sendingMessage: Boolean = false,
val chatListLoading: Boolean = false,
val chatListConnectionIssue: Boolean = false,
val error: String? = null,
val previewImageUrl: String? = null,
val previewImageGallery: List<String> = emptyList(),
val previewImageIndex: Int = 0,
val cachedImagePaths: Map<String, String> = emptyMap()
)
class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
var state = androidx.compose.runtime.mutableStateOf(QMaxUiState())
private set
private val realtime = QMaxRealtimeClient()
private var messagePollJob: Job? = null
private var presencePollJob: Job? = null
private var realtimeConnectJob: Job? = null
private var chatListRefreshJob: Job? = null
private var chatRefreshJob: Job? = null
private var chatRefreshGeneration = 0L
private var chatSearchJob: Job? = null
private var autoLoginJob: Job? = null
private var pushRegisteredForToken: String? = null
private var autoLoginAttempted = false
private var pendingOpenChatId: String? = null
private val locallyReadChatFingerprints = mutableMapOf<String, String>()
private val imageCacheInFlight = mutableSetOf<String>()
init {
viewModelScope.launch {
repository.session.collectLatest { session ->
val previousSession = state.value.session
val sessionChanged = previousSession?.serverUrl != session?.serverUrl ||
previousSession?.userName != session?.userName
state.value = state.value.copy(
session = session,
serverUrl = session?.serverUrl ?: state.value.serverUrl,
cachedImagePaths = if (sessionChanged) emptyMap() else state.value.cachedImagePaths
)
if (session != null) {
autoLoginJob?.cancel()
restoreCachedChats(session)
loadChats()
connectRealtime(session)
registerPushDevice(session)
loadMaxStatus()
} else {
realtimeConnectJob?.cancel()
messagePollJob?.cancel()
presencePollJob?.cancel()
realtime.disconnect()
if (!autoLoginAttempted && state.value.pairingCode.isNotBlank()) {
autoLoginAttempted = true
autoLoginJob?.cancel()
autoLoginJob = viewModelScope.launch {
delay(AutoLoginDelayMs)
if (state.value.session == null) {
login()
}
}
}
}
}
}
viewModelScope.launch {
repository.drafts.collectLatest { drafts ->
state.value = state.value.copy(drafts = drafts)
}
}
}
fun updateServerUrl(value: String) {
state.value = state.value.copy(serverUrl = value)
}
fun updatePairingCode(value: String) {
state.value = state.value.copy(pairingCode = value)
}
fun updateComposer(value: String) {
state.value = state.value.copy(composerText = value)
if (state.value.editTarget != null) {
return
}
val chatId = state.value.selectedChat?.id ?: return
viewModelScope.launch {
repository.saveDraft(chatId, value)
}
}
fun updateSearchQuery(value: String) {
state.value = state.value.copy(searchQuery = value)
}
fun updateNewChatExternalId(value: String) {
state.value = state.value.copy(newChatExternalId = value)
}
fun updateNewChatTitle(value: String) {
state.value = state.value.copy(newChatTitle = value)
}
fun updateMaxCode(value: String) {
state.value = state.value.copy(maxCode = value)
}
fun login() = launchLoading {
val current = state.value
repository.login(current.serverUrl, current.pairingCode)
}
fun logout() = viewModelScope.launch {
autoLoginAttempted = true
repository.logout()
realtime.disconnect()
state.value = QMaxUiState(serverUrl = state.value.serverUrl, pairingCode = "")
}
fun loadChats() = refreshChats()
private suspend fun restoreCachedChats(session: QMaxSession) {
runCatching {
repository.cachedChats(session)
}.getOrNull()?.takeIf { it.isNotEmpty() }?.let { cached ->
val currentSession = state.value.session
if (currentSession?.serverUrl == session.serverUrl && currentSession.userName == session.userName) {
val orderedCached = normalizeChats(cached)
state.value = state.value.copy(
chats = orderedCached,
chatListLoading = false,
chatListConnectionIssue = false,
error = null
)
openPendingChatIfAvailable(orderedCached)
}
}
}
private fun refreshChats(showLoading: Boolean = true) {
if (showLoading) {
chatRefreshJob?.cancel()
} else if (chatRefreshJob?.isActive == true) {
return
}
val generation = ++chatRefreshGeneration
chatRefreshJob = viewModelScope.launch {
if (showLoading) {
state.value = state.value.copy(
chatListLoading = state.value.chats.isEmpty(),
error = null
)
}
try {
val session = requireSession()
val cached = repository.cachedChats(session)
if (cached.isNotEmpty()) {
val orderedCached = normalizeChats(cached)
state.value = state.value.copy(
chats = orderedCached,
chatListLoading = false
)
openPendingChatIfAvailable(orderedCached)
}
val fresh = withTimeout(ChatListTimeoutMs) {
repository.chats(session)
}
if (fresh.isEmpty()) {
error("Chat list response is empty.")
}
val orderedFresh = normalizeChats(fresh)
repository.cacheChats(session, orderedFresh)
state.value = state.value.copy(
chats = orderedFresh,
chatListLoading = false,
chatListConnectionIssue = false,
error = null
)
openPendingChatIfAvailable(orderedFresh)
} catch (error: TimeoutCancellationException) {
Log.w(LogTag, "chat refresh timed out", error)
if (generation == chatRefreshGeneration) {
state.value = state.value.copy(
chatListLoading = false,
chatListConnectionIssue = true
)
}
} catch (error: CancellationException) {
if (generation == chatRefreshGeneration) {
throw error
}
} catch (error: Throwable) {
Log.w(LogTag, "chat refresh failed", error)
if (generation == chatRefreshGeneration) {
state.value = state.value.copy(
chatListLoading = false,
chatListConnectionIssue = true
)
}
} finally {
if (showLoading && generation == chatRefreshGeneration) {
state.value = state.value.copy(chatListLoading = false)
}
}
}
}
fun createChat() = launchLoading {
val session = requireSession()
val current = state.value
val externalId = current.newChatExternalId.ifBlank { current.newChatTitle }
val title = current.newChatTitle.ifBlank { externalId }
if (externalId.isBlank()) return@launchLoading
val chat = repository.createChat(session, externalId, title)
state.value = state.value.copy(newChatExternalId = "", newChatTitle = "")
openChat(chat)
loadChats()
}
fun openChat(chat: ChatDto) {
saveCurrentDraft()
chatSearchJob?.cancel()
val openedChat = chat.copy(unreadCount = 0)
rememberChatRead(openedChat)
val updatedChats = normalizeChats(
state.value.chats.map { current ->
if (current.id == chat.id) current.copy(unreadCount = 0) else current
},
selectedChatId = chat.id
)
state.value = state.value.copy(
selectedChat = openedChat,
chats = updatedChats,
messages = emptyList(),
chatSearchResults = emptyList(),
chatSearchLoading = false,
chatPresenceText = null,
replyTarget = null,
editTarget = null,
forwardTarget = null,
composerText = ""
)
persistCurrentChats()
syncChatRead(chat.id)
messagePollJob?.cancel()
viewModelScope.launch {
val draft = repository.draft(chat.id)
if (state.value.selectedChat?.id == chat.id) {
state.value = state.value.copy(composerText = draft)
}
realtime.joinChat(chat.id)
}
loadMessages(showLoading = true)
presencePollJob?.cancel()
presencePollJob = viewModelScope.launch {
while (state.value.selectedChat?.id == chat.id) {
refreshPresence(chat.id)
delay(2_000)
}
}
messagePollJob = viewModelScope.launch {
while (true) {
delay(30000)
if (!state.value.sendingMessage) {
loadMessages(showLoading = false)
}
}
}
}
fun openChatById(chatId: String) {
val chat = state.value.chats.firstOrNull { it.id.equals(chatId, ignoreCase = true) }
if (chat != null) {
pendingOpenChatId = null
if (state.value.selectedChat?.id != chat.id) {
openChat(chat)
}
return
}
pendingOpenChatId = chatId
if (state.value.session != null) {
loadChats()
}
}
fun closeChat() {
val closingChat = state.value.selectedChat
val closingChatId = closingChat?.id
saveCurrentDraft()
messagePollJob?.cancel()
presencePollJob?.cancel()
viewModelScope.launch {
realtime.joinChat(null)
}
chatSearchJob?.cancel()
val reorderedChats = if (closingChatId.isNullOrBlank()) {
state.value.chats
} else {
closingChat?.let(::rememberChatRead)
normalizeChats(state.value.chats.map { chat ->
if (chat.id == closingChatId) chat.copy(unreadCount = 0) else chat
}, selectedChatId = closingChatId)
}
state.value = state.value.copy(
selectedChat = null,
chats = reorderedChats,
messages = emptyList(),
chatSearchResults = emptyList(),
chatSearchLoading = false,
chatPresenceText = null,
replyTarget = null,
editTarget = null,
forwardTarget = null,
composerText = ""
)
persistCurrentChats()
closingChatId?.let(::syncChatRead)
}
fun loadMessages(showLoading: Boolean = true) = launchLoading(showLoading) {
if (!showLoading && state.value.sendingMessage) {
return@launchLoading
}
val session = requireSession()
val chat = state.value.selectedChat ?: return@launchLoading
val cached = repository.cachedMessages(session, chat.id)
if (cached.isNotEmpty() && state.value.selectedChat?.id == chat.id && state.value.messages.isEmpty()) {
state.value = state.value.copy(messages = cached)
}
val fresh = withTimeout(MessageSyncTimeoutMs) {
repository.messages(session, chat.id)
}
if (state.value.selectedChat?.id == chat.id) {
val selected = state.value.selectedChat?.copy(unreadCount = 0)
selected?.let(::rememberChatRead)
state.value = state.value.copy(
selectedChat = selected,
chats = normalizeChats(state.value.chats, selectedChatId = chat.id),
messages = fresh
)
persistCurrentChats()
}
}
fun sendMessage() = sendComposer(emptyList())
fun sendComposer(attachmentUris: List<Uri>, onSuccess: () -> Unit = {}) = launchLoading(false) {
val session = requireSession()
val chat = state.value.selectedChat ?: return@launchLoading
val text = state.value.composerText.trim()
if (text.isBlank() && attachmentUris.isEmpty()) return@launchLoading
state.value = state.value.copy(sendingMessage = true, error = null)
try {
val editTarget = state.value.editTarget
if (editTarget != null) {
if (text.isBlank()) return@launchLoading
val edited = repository.editMessage(session, chat.id, editTarget.id, text)
repository.clearDraft(chat.id)
val current = state.value
if (current.selectedChat?.id == chat.id) {
state.value = current.copy(
composerText = "",
editTarget = null,
replyTarget = null,
messages = upsertMessage(current.messages, edited)
)
}
onSuccess()
loadChats()
return@launchLoading
}
val replyToMessageId = state.value.replyTarget?.id
val sentMessages = mutableListOf<MessageDto>()
if (attachmentUris.isEmpty()) {
sentMessages += repository.sendMessage(session, chat.id, text, replyToMessageId)
} else {
attachmentUris.forEachIndexed { index, uri ->
val caption = text.takeIf { index == 0 && it.isNotBlank() }
sentMessages += repository.upload(session, chat.id, uri, caption, replyToMessageId)
}
}
repository.clearDraft(chat.id)
val current = state.value
if (current.selectedChat?.id == chat.id) {
state.value = current.copy(
composerText = "",
replyTarget = null,
messages = sentMessages.fold(current.messages, ::upsertMessage)
)
}
onSuccess()
loadChats()
} finally {
state.value = state.value.copy(sendingMessage = false)
}
}
fun upload(uri: Uri) = launchLoading(false) {
val session = requireSession()
val chat = state.value.selectedChat ?: return@launchLoading
val caption = state.value.composerText.ifBlank { null }
val uploaded = repository.upload(session, chat.id, uri, caption, state.value.replyTarget?.id)
repository.clearDraft(chat.id)
val current = state.value
state.value = current.copy(
composerText = "",
replyTarget = null,
messages = if (current.selectedChat?.id == chat.id) {
upsertMessage(current.messages, uploaded)
} else {
current.messages
}
)
loadChats()
}
fun uploadVoice(file: File) = launchLoading(false) {
try {
val session = requireSession()
val chat = state.value.selectedChat ?: return@launchLoading
val uploaded = repository.uploadVoice(session, chat.id, file, state.value.replyTarget?.id)
val current = state.value
state.value = current.copy(
replyTarget = null,
messages = if (current.selectedChat?.id == chat.id) {
upsertMessage(current.messages, uploaded)
} else {
current.messages
}
)
loadChats()
} finally {
file.delete()
}
}
fun loadMaxStatus() = launchLoading(false) {
val session = requireSession()
state.value = state.value.copy(maxStatus = repository.maxStatus(session))
}
fun startMaxLogin() = launchLoading {
val session = requireSession()
state.value = state.value.copy(maxStatus = repository.startMaxLogin(session, null))
}
fun submitMaxCode() = launchLoading {
val session = requireSession()
val code = state.value.maxCode.trim()
if (code.isBlank()) return@launchLoading
state.value = state.value.copy(maxStatus = repository.submitMaxCode(session, code), maxCode = "")
}
fun checkArgusUpdate() = launchLoading {
val manifest = repository.checkArgusUpdate()
if (manifest == null) {
state.value = state.value.copy(updateMessage = "Обновлений нет")
} else {
state.value = state.value.copy(updateMessage = "Найдена версия ${manifest.release.version}")
repository.downloadAndOpenArgusUpdate(manifest)
}
}
fun openImage(url: String, gallery: List<String> = emptyList()) {
val images = (gallery.ifEmpty { listOf(url) })
.filter { it.isNotBlank() }
.distinct()
val selectedIndex = images.indexOf(url).takeIf { it >= 0 } ?: 0
state.value = state.value.copy(
previewImageUrl = images.getOrNull(selectedIndex) ?: url,
previewImageGallery = images,
previewImageIndex = selectedIndex
)
}
fun closeImage() {
state.value = state.value.copy(
previewImageUrl = null,
previewImageGallery = emptyList(),
previewImageIndex = 0
)
}
fun moveImagePreview(delta: Int) {
val gallery = state.value.previewImageGallery
if (gallery.size < 2) return
val nextIndex = (state.value.previewImageIndex + delta + gallery.size) % gallery.size
state.value = state.value.copy(
previewImageUrl = gallery[nextIndex],
previewImageIndex = nextIndex
)
}
fun openAttachment(attachment: AttachmentDto) = launchLoading(false) {
val session = requireSession()
repository.openAttachment(session, attachment)
}
fun shareMessage(message: MessageDto) = launchLoading(false) {
val session = requireSession()
repository.shareMessage(session, message)
}
fun shareAttachment(attachment: AttachmentDto) = launchLoading(false) {
val session = requireSession()
repository.shareAttachment(session, attachment)
}
fun replyTo(message: MessageDto) {
state.value = state.value.copy(replyTarget = message, editTarget = null)
}
fun clearReplyTarget() {
state.value = state.value.copy(replyTarget = null)
}
fun openEditMessage(message: MessageDto) {
if (!message.direction.equals("Outgoing", ignoreCase = true) || message.text.isNullOrBlank()) {
return
}
state.value = state.value.copy(
editTarget = message,
replyTarget = null,
composerText = message.text
)
}
fun clearEditTarget() {
val chatId = state.value.selectedChat?.id
state.value = state.value.copy(editTarget = null, composerText = "")
if (chatId != null) {
viewModelScope.launch {
val draft = repository.draft(chatId)
if (state.value.selectedChat?.id == chatId && state.value.editTarget == null) {
state.value = state.value.copy(composerText = draft)
}
}
}
}
fun deleteMessage(message: MessageDto) = launchLoading(false) {
val session = requireSession()
repository.deleteMessage(session, message.chatId, message.id)
val current = state.value
state.value = current.copy(
messages = current.messages.filterNot { it.id == message.id },
replyTarget = current.replyTarget?.takeIf { it.id != message.id },
editTarget = current.editTarget?.takeIf { it.id != message.id },
forwardTarget = current.forwardTarget?.takeIf { it.id != message.id }
)
loadChats()
}
fun toggleReaction(message: MessageDto, emoji: String) = launchLoading(false) {
val session = requireSession()
val currentReaction = message.reactions.firstOrNull { it.reactedByMe }
val updated = if (currentReaction?.emoji == emoji) {
repository.clearReaction(session, message.chatId, message.id)
} else {
repository.setReaction(session, message.chatId, message.id, emoji)
}
val current = state.value
if (current.selectedChat?.id == updated.chatId) {
state.value = current.copy(
messages = (current.messages.filterNot { it.id == updated.id } + updated).sortedBy { it.sentAt },
replyTarget = current.replyTarget?.let { if (it.id == updated.id) updated else it },
editTarget = current.editTarget?.let { if (it.id == updated.id) updated else it },
forwardTarget = current.forwardTarget?.let { if (it.id == updated.id) updated else it }
)
}
}
fun openForwardPicker(message: MessageDto) {
state.value = state.value.copy(forwardTarget = message)
}
fun closeForwardPicker() {
state.value = state.value.copy(forwardTarget = null)
}
fun forwardTo(chat: ChatDto) = launchLoading(false) {
val session = requireSession()
val source = state.value.forwardTarget ?: return@launchLoading
val forwarded = repository.forwardMessage(session, source.chatId, source.id, chat.id)
val current = state.value
state.value = if (current.selectedChat?.id == chat.id) {
current.copy(
forwardTarget = null,
messages = (current.messages.filterNot { it.id == forwarded.id } + forwarded).sortedBy { it.sentAt }
)
} else {
current.copy(forwardTarget = null)
}
loadChats()
}
fun searchChatMessages(query: String) {
chatSearchJob?.cancel()
val trimmed = query.trim()
val session = state.value.session
val chat = state.value.selectedChat
if (trimmed.isBlank() || session == null || chat == null) {
state.value = state.value.copy(chatSearchResults = emptyList(), chatSearchLoading = false)
return
}
val chatId = chat.id
chatSearchJob = viewModelScope.launch {
delay(250)
state.value = state.value.copy(chatSearchLoading = true)
try {
val results = withTimeout(ChatListTimeoutMs) {
repository.searchMessages(session, chatId, trimmed)
}
if (state.value.selectedChat?.id == chatId) {
state.value = state.value.copy(
chatSearchResults = results,
chatSearchLoading = false,
error = null
)
}
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
if (state.value.selectedChat?.id == chatId) {
state.value = state.value.copy(chatSearchLoading = false)
}
}
}
}
fun cacheImage(attachment: AttachmentDto) {
val session = state.value.session ?: return
val key = attachment.id
if (state.value.cachedImagePaths.containsKey(key) || !imageCacheInFlight.add(key)) {
return
}
viewModelScope.launch {
try {
val file = repository.cachedAttachmentFile(session, attachment)
val currentSession = state.value.session
if (currentSession?.serverUrl == session.serverUrl && currentSession.userName == session.userName) {
state.value = state.value.copy(
cachedImagePaths = state.value.cachedImagePaths + (key to file.absolutePath)
)
}
} catch (_: Throwable) {
// The network-backed image loader remains as a fallback if the local cache cannot be populated.
} finally {
imageCacheInFlight.remove(key)
}
}
}
private fun requireSession(): QMaxSession = state.value.session ?: error("No QMAX session")
private fun saveCurrentDraft() {
val current = state.value
if (current.editTarget != null) {
return
}
val chatId = current.selectedChat?.id ?: return
viewModelScope.launch {
repository.saveDraft(chatId, current.composerText)
}
}
private fun openPendingChatIfAvailable(chats: List<ChatDto>) {
val chatId = pendingOpenChatId ?: return
val chat = chats.firstOrNull { it.id.equals(chatId, ignoreCase = true) } ?: return
pendingOpenChatId = null
if (state.value.selectedChat?.id != chat.id) {
openChat(chat)
}
}
private suspend fun refreshPresence(chatId: String) {
val session = state.value.session ?: return
val presence = runCatching {
repository.chatPresence(session, chatId)
}.getOrNull() ?: return
if (state.value.selectedChat?.id != chatId) {
return
}
val text = if (presence.isTyping) {
presence.statusText?.takeIf { it.isNotBlank() } ?: "\u043f\u0435\u0447\u0430\u0442\u0430\u0435\u0442..."
} else {
null
}
state.value = state.value.copy(chatPresenceText = text)
}
private fun registerPushDevice(session: QMaxSession) {
val registrationKey = "${session.serverUrl}:${session.accessToken.take(12)}"
if (pushRegisteredForToken == registrationKey) {
return
}
pushRegisteredForToken = registrationKey
viewModelScope.launch {
runCatching {
repository.registerCurrentPushDevice(session)
}
}
}
private fun connectRealtime(session: QMaxSession) {
realtimeConnectJob?.cancel()
realtimeConnectJob = viewModelScope.launch {
runCatching {
realtime.connect(
session = session,
onChatListInvalidated = ::scheduleChatListRefresh,
onMessageCreated = ::handleRealtimeMessage,
onMessageUpdated = ::handleRealtimeMessageUpdated,
onMessageDeleted = ::handleRealtimeMessageDeleted
)
realtime.joinChat(state.value.selectedChat?.id)
}
}
}
private fun scheduleChatListRefresh() {
chatListRefreshJob?.cancel()
chatListRefreshJob = viewModelScope.launch {
delay(250)
refreshChats(showLoading = false)
}
}
private fun handleRealtimeMessage(message: MessageDto) {
viewModelScope.launch {
val current = state.value
current.session?.let { repository.cacheRealtimeMessage(it, message) }
if (current.selectedChat?.id == message.chatId &&
current.messages.none { it.id == message.id }) {
val updatedChats = normalizeChats(
current.chats.map { chat ->
if (chat.id == message.chatId) {
chat.copy(
lastMessagePreview = message.text ?: chat.lastMessagePreview,
lastMessageAt = message.sentAt,
unreadCount = 0
)
} else {
chat
}
},
selectedChatId = message.chatId
)
state.value = current.copy(
chats = updatedChats,
messages = (current.messages + message).sortedBy { it.sentAt }
)
syncChatRead(message.chatId)
}
refreshChats(showLoading = false)
}
}
private fun handleRealtimeMessageUpdated(message: MessageDto) {
viewModelScope.launch {
val current = state.value
current.session?.let { repository.cacheRealtimeMessage(it, message) }
if (current.selectedChat?.id == message.chatId) {
state.value = current.copy(
messages = (current.messages.filterNot { it.id == message.id } + message).sortedBy { it.sentAt },
replyTarget = current.replyTarget?.let { if (it.id == message.id) message else it },
editTarget = current.editTarget?.let { if (it.id == message.id) message else it },
forwardTarget = current.forwardTarget?.let { if (it.id == message.id) message else it }
)
}
refreshChats(showLoading = false)
}
}
private fun handleRealtimeMessageDeleted(deleted: MessageDeletedDto) {
viewModelScope.launch {
val current = state.value
current.session?.let { repository.deleteCachedMessage(it, deleted.chatId, deleted.messageId) }
if (current.selectedChat?.id == deleted.chatId) {
state.value = current.copy(
messages = current.messages.filterNot { it.id == deleted.messageId },
replyTarget = current.replyTarget?.takeIf { it.id != deleted.messageId },
editTarget = current.editTarget?.takeIf { it.id != deleted.messageId },
forwardTarget = current.forwardTarget?.takeIf { it.id != deleted.messageId }
)
}
refreshChats(showLoading = false)
}
}
private fun launchLoading(showLoading: Boolean = true, block: suspend () -> Unit) {
viewModelScope.launch {
if (showLoading) state.value = state.value.copy(loading = true, error = null)
try {
block()
state.value = state.value.copy(error = null)
} catch (error: Throwable) {
Log.w(LogTag, "launchLoading failed", error)
state.value = state.value.copy(error = error.message ?: "Ошибка")
} finally {
if (showLoading) state.value = state.value.copy(loading = false)
}
}
}
private fun upsertMessage(messages: List<MessageDto>, message: MessageDto): List<MessageDto> {
return (messages.filterNot { it.id == message.id } + message).sortedBy { it.sentAt }
}
private fun normalizeChats(
chats: List<ChatDto>,
selectedChatId: String? = state.value.selectedChat?.id
): List<ChatDto> {
val normalized = chats.map { chat ->
val fingerprint = chatReadFingerprint(chat)
val isSelectedChat = selectedChatId != null && chat.id == selectedChatId
if (isSelectedChat) {
locallyReadChatFingerprints[chat.id] = fingerprint
}
val shouldKeepRead = isSelectedChat || locallyReadChatFingerprints[chat.id] == fingerprint
if (shouldKeepRead && chat.unreadCount != 0) {
chat.copy(unreadCount = 0)
} else {
chat
}
}
return orderedChats(normalized)
}
private fun rememberChatRead(chat: ChatDto) {
locallyReadChatFingerprints[chat.id] = chatReadFingerprint(chat)
}
private fun chatReadFingerprint(chat: ChatDto): String {
return "${chat.lastMessageAt.orEmpty()}\n${chat.lastMessagePreview.orEmpty()}"
}
private fun persistCurrentChats() {
val session = state.value.session ?: return
val chats = state.value.chats
viewModelScope.launch {
runCatching {
repository.cacheChats(session, chats)
}
}
}
private fun syncChatRead(chatId: String) {
val session = state.value.session ?: return
viewModelScope.launch {
runCatching {
repository.markChatRead(session, chatId)
}
runCatching {
repository.cacheChats(session, state.value.chats)
}
}
}
private fun orderedChats(chats: List<ChatDto>): List<ChatDto> {
return chats.sortedWith(
compareByDescending<ChatDto> { it.isPinned }
.thenByDescending { it.unreadCount > 0 }
.thenByDescending { it.lastMessageAt.orEmpty() }
.thenBy { it.title.lowercase() }
)
}
class Factory(private val repository: QMaxRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return QMaxViewModel(repository) as T
}
}
override fun onCleared() {
super.onCleared()
viewModelScope.launch {
realtime.disconnect()
}
}
private companion object {
const val LogTag = "QMAX"
const val ChatListTimeoutMs = 45_000L
const val MessageSyncTimeoutMs = 120_000L
const val AutoLoginDelayMs = 1_000L
}
}
@@ -0,0 +1,35 @@
package xyz.kusoft.qmax.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
val QMaxBlue = Color(0xFF3390EC)
val QMaxText = Color(0xFF17212B)
val QMaxMuted = Color(0xFF6C7883)
val QMaxBackground = Color(0xFFF3F7FA)
val QMaxIncoming = Color.White
val QMaxOutgoing = Color(0xFFE7F7C8)
val QMaxUnread = QMaxBlue
private val Colors: ColorScheme = lightColorScheme(
primary = QMaxBlue,
onPrimary = Color.White,
background = QMaxBackground,
onBackground = QMaxText,
surface = Color.White,
onSurface = QMaxText,
surfaceVariant = Color(0xFFE9EEF2),
onSurfaceVariant = QMaxMuted
)
@Composable
fun QMaxTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = Colors,
typography = MaterialTheme.typography,
content = content
)
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7 19.6,5.6z" />
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path android:fillColor="#3390EC" android:pathData="M24,4C12.95,4 4,12.95 4,24s8.95,20 20,20 20,-8.95 20,-20S35.05,4 24,4z" />
<path android:fillColor="#FFFFFF" android:pathData="M13,24L35,14L30,35L24,28L19,33L20,26z" />
</vector>
@@ -0,0 +1,3 @@
<resources>
<color name="qmax_blue">#3390EC</color>
</resources>
@@ -0,0 +1,7 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowLightStatusBar">true</item>
<item name="android:statusBarColor">@android:color/white</item>
<item name="android:navigationBarColor">@android:color/white</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="cache" path="." />
<files-path name="files" path="." />
</paths>
+6
View File
@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.10" apply false
id("com.google.gms.google-services") version "4.4.4" apply false
}
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
org.gradle.workers.max=2
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.daemon.jvmargs=-Xmx2048m
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+4
View File
@@ -0,0 +1,4 @@
storeFile=qmax-release.jks
storePassword=cc1ed02770207c5522192ea1f9eb3e4dc52040ac
keyAlias=qmax-release
keyPassword=cc1ed02770207c5522192ea1f9eb3e4dc52040ac
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "QMAX"
include(":app")