Initial QMAX production app
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
Vendored
+248
@@ -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" "$@"
|
||||
Vendored
+82
@@ -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%
|
||||
@@ -0,0 +1,4 @@
|
||||
storeFile=qmax-release.jks
|
||||
storePassword=cc1ed02770207c5522192ea1f9eb3e4dc52040ac
|
||||
keyAlias=qmax-release
|
||||
keyPassword=cc1ed02770207c5522192ea1f9eb3e4dc52040ac
|
||||
Binary file not shown.
@@ -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")
|
||||
Reference in New Issue
Block a user